An enemy class Spiker that extends Enemy. It implements behavior for spawning, moving, circling, shooting spiker head projectiles, digging underground and resurfacing, plus state machine, animations and networked RPCs to broadcast effects and state changes.
using System;
using Sandbox;
public class Spiker : Enemy
{
public override EnemyType EnemyType => EnemyType.Spiker;
public override float GetMaxHealth()
{
switch ( Manager.Instance.Difficulty )
{
case 0: default: return 75f;
case 1: return 80f;
case 2: return 80f;
}
}
public override Vector3 SpawnScale => new Vector3( 1.25f );
public override int ExtraDeathBloodSprayAmount => 15;
protected float _shootDelayTimer;
protected float _shootDelayMin;
protected float _shootDelayMax;
protected float _shootReverseDelay;
protected float _shootRange;
protected Vector2 _cracksPos;
protected string _projectileName;
protected Color _crackParticleColor;
protected float _maxExtraHeadDist;
protected float _digDelayTimer;
protected float _digDelayMin;
protected float _digDelayMax;
protected float _digRange;
protected float _digOffsetMin;
protected float _digOffsetMax;
protected TimeSince _timeSinceDigging;
protected float _digTime;
protected Vector2 _digPos;
protected float _waitUndergroundTime;
public override bool CanAttack => base.CanAttack && State == SpikerState.Default;
public override bool CanMove => base.CanMove && State == SpikerState.Default;
public override bool CanTurn => base.CanTurn && State == SpikerState.Default;
public override bool CanBeStunned => base.CanBeStunned && !(State == SpikerState.DigStart || State == SpikerState.DigWaitUnderground );
protected bool _cantBePushed;
protected bool _moveClockwise;
public override float SpawnZPos => -120f;
protected int _numTimesShot;
protected int _numShotsTotal;
protected virtual bool ShouldCircleTarget => !IsAttacking || (Utils.FastSin( TimeSinceSpawn * 2f * _personalSpeedScale ) < 0.2f);
protected enum SpikerState
{
Default,
ShootStart,
ShootSpawnCracks,
Shoot,
ShootReverse,
DigStart,
DigWaitUnderground,
DigFinish,
}
protected SpikerState State { get; private set; } = SpikerState.Default;
protected override void OnStart()
{
base.OnStart();
CoinValueMin = 3;
CoinValueMax = 4;
CoinChance = 1f;
PushStrength = 7500f;
Weight = 1.5f;
_personalSpeedScale = Game.Random.Float( 0.95f, 1.05f );
_personalSpeedFreq = Game.Random.Float( 7f, 9f );
_fullMeleeAttackAnimSpeed = 1.5f;
_projectileName = "spiker_head";
_crackParticleColor = new Color( 0.02f, 0.02f, 0.01f );
if ( IsProxy )
return;
AggroRange = 65f;
DetectTargetRange = 650f;
LoseTargetRange = 950f;
LoseTargetTime = 5f;
MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 11f, 12f, 13f );
DamageTargetDelay = 0.75f;
_personalTurnSpeed = Game.Random.Float( 3f, 6f );
Acceleration = 180f;
AccelerationAttacking = 220f;
Deceleration = 2.3f;
DecelerationAttacking = 2.1f;
_shootDelayMin = 2f;
_shootDelayMax = 7f;
_shootRange = 500f;
_maxExtraHeadDist = 14f;
_shootReverseDelay = 0.4f;
_digDelayMin = 5f;
_digDelayMax = 10f;
_digRange = 700f;
_digTime = 1.5f;
_digOffsetMin = 200f;
_digOffsetMax = 400f;
_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
_digDelayTimer = Game.Random.Float( _digDelayMin, _digDelayMax );
_moveClockwise = Game.Random.Int( 0, 1 ) == 0;
}
protected override void OnUpdate()
{
base.OnUpdate();
//Gizmo.Draw.Color = Color.White;
//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;
if ( IsProxy )
return;
if ( !IsSpawning && !IsStunned )
HandleState();
}
protected void HandleState()
{
switch ( State )
{
case SpikerState.Default:
if ( TargetUnit.IsValid() && !IsInTheAir && _timeSinceChangeState > 0.5f )
{
var targetDistSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
if ( targetDistSqr < MathF.Pow( _shootRange, 2f ) )
{
_shootDelayTimer -= Time.Delta * TimeScale;
if ( _shootDelayTimer < 0f )
{
_numTimesShot = 0;
_numShotsTotal = GetNumShotsTotal();
SetState( SpikerState.ShootStart );
}
}
if ( targetDistSqr < MathF.Pow( _digRange, 2f ) )
{
_digDelayTimer -= Time.Delta * TimeScale;
if ( _digDelayTimer < 0f )
SetState( SpikerState.DigStart );
}
}
break;
case SpikerState.ShootStart:
if ( _timeSinceChangeState > GetShootSpawnCracksDelay() )
SetState( SpikerState.ShootSpawnCracks );
break;
case SpikerState.ShootSpawnCracks:
ClearVelocity();
if ( _timeSinceChangeState > 0.7f )
SetState( SpikerState.Shoot );
break;
case SpikerState.Shoot:
ClearVelocity();
if( _numTimesShot >= _numShotsTotal )
{
if ( _timeSinceChangeState > 1.5f )
SetState( SpikerState.ShootReverse );
}
else
{
if ( _timeSinceChangeState > 0.6f )
SetState( SpikerState.ShootStart);
}
break;
case SpikerState.ShootReverse:
ClearVelocity();
if ( _timeSinceChangeState > _shootReverseDelay )
SetState( SpikerState.Default );
break;
case SpikerState.DigStart:
var digTime = 1.5f;
var zPos = Utils.Map( _timeSinceChangeState, 0f, digTime, 0f, SpawnZPos );
WorldPosition = WorldPosition.WithZ( zPos );
SpawnProgress = Utils.Map( _timeSinceChangeState, 0f, digTime, 1f, 0f );
if ( _timeSinceChangeState > digTime )
SetState( SpikerState.DigWaitUnderground);
break;
case SpikerState.DigWaitUnderground:
if ( _timeSinceChangeState > _waitUndergroundTime )
SetState( SpikerState.DigFinish );
break;
}
}
protected void SetState( SpikerState state )
{
State = state;
_timeSinceChangeState = 0f;
switch ( state )
{
case SpikerState.Default:
EnterDefaultStateRpc();
break;
case SpikerState.ShootStart:
_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
StartShootingRpc();
break;
case SpikerState.ShootSpawnCracks:
var targetPlayer = TargetUnit.IsValid() && TargetUnit is Player ? TargetUnit as Player : Manager.Instance.GetClosestPlayer( Position2D );
var targetPos = targetPlayer.IsValid()
? GetShootTargetPos( targetPlayer )
: Position2D + Utils.GetRandomVector() * Game.Random.Float( 100f, 400f );
Manager.Instance.AvoidObstacles( ref targetPos, radius: 22f, andSpikerHeads: true );
_cracksPos = Manager.Instance.ClampPosToBounds( targetPos, buffer: 10f );
SpawnCracksRpc( _cracksPos );
break;
case SpikerState.Shoot:
var headPos = _cracksPos;
var player = TargetUnit.IsValid() && TargetUnit is Player ? TargetUnit as Player : Manager.Instance.GetClosestPlayer( Position2D );
if ( player.IsValid() )
{
// extra distance to try to hit player
headPos += (player.Position2D - headPos).Normal * Math.Min( (player.Position2D - headPos).Length, _maxExtraHeadDist );
}
//var zPos = -200f;
var zPos = 0f;
var headGo = GameObject.Clone( $"prefabs/{_projectileName}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( headPos.x, headPos.y, zPos ), new Angles( 0f, Game.Random.Float( -60f, -120f ), 0f ) ) } );
var head = headGo.Components.Get<SpikerHead>( true );
head.Shooter = this;
head.ModelRenderer.Tint = ModelRenderer.Tint;
headGo.NetworkSpawn();
Manager.Instance.SpawnSpikerHeadArea( headPos, 30f, 1.3f );
_numTimesShot++;
break;
case SpikerState.ShootReverse:
//EnterDefaultStateRpc();
break;
case SpikerState.DigStart:
_digDelayTimer = Game.Random.Float( _digDelayMin, _digDelayMax );
StartDiggingRpc();
break;
case SpikerState.DigWaitUnderground:
_waitUndergroundTime = Game.Random.Float( 0.5f, 1f );
break;
case SpikerState.DigFinish:
SetState( SpikerState.Default );
Vector2 pos = TargetUnit.IsValid()
? TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 1.5f ) + Utils.GetRandomVector() * Game.Random.Float( _digOffsetMin, _digOffsetMax )
: Position2D + Utils.GetRandomVector() * Game.Random.Float( _digOffsetMin, _digOffsetMax );
FinishDiggingRpc( Manager.Instance.ClampPosToBounds( pos, buffer: 10f ) );
WorldPosition = new Vector3( pos.x, pos.y, SpawnZPos );
Transform.ClearInterpolation();
_moveClockwise = !_moveClockwise;
break;
}
}
protected virtual float GetShootSpawnCracksDelay()
{
return 0.5f;
}
protected virtual int GetNumShotsTotal()
{
return 1;
}
void ClearVelocity()
{
Velocity = Vector2.Zero;
ResetRepelVelocity();
ExplosionVelocity = Vector2.Zero;
}
protected override float GetMoveSpeedFactor()
{
var leftFootStart = 0.2f;
var leftFootEnd = 0.60f;
var rightFootStart = 0.60f;
var rightFootEnd = 1.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.Linear );
}
else if ( progress > leftFootStart && progress < leftFootEnd )
{
return Utils.Map( progress, leftFootStart, leftFootEnd, 0f, 1f, EasingType.Linear );
}
return 0f;
}
protected override void HandleRotation()
{
if( !HasTarget || !ShouldCircleTarget )
{
base.HandleRotation();
return;
}
Vector2 toTarget = TargetPos - Position2D;
if ( (TargetPos - Position2D).LengthSquared < MathF.Pow( 600f, 2f ) )
toTarget = Vector2.Lerp( toTarget, new Vector2( toTarget.y, -toTarget.x ) * (_moveClockwise ? -1f : 1f), Utils.Map( (TargetPos - Position2D).LengthSquared, MathF.Pow( 600f, 2f ), MathF.Pow( 200f, 2f ), 0f, 1f ) );
WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( (Vector3)toTarget), _personalTurnSpeed * Time.Delta * TimeScale );
}
public override void StartAttacking()
{
base.StartAttacking();
if ( _timeSinceStartAttackingSfx > Game.Random.Float( 0.8f, 1.3f ) )
{
Manager.Instance.PlaySfxNearby( "monster.scream1", Position2D, pitch: Game.Random.Float( 1f, 1.2f ), volume: 1.3f, maxDist: 300f );
_timeSinceStartAttackingSfx = 0f;
}
}
[Rpc.Broadcast]
protected void StartShootingRpc()
{
StartShooting();
}
protected virtual void StartShooting()
{
CanAnimate = false;
SetAnim( "Shoot" );
SetPlaybackRate( 0.7f );
}
protected virtual Vector2 GetShootTargetPos( Player targetPlayer )
{
return targetPlayer.IsValid()
? targetPlayer.Position2D + targetPlayer.Velocity * Game.Random.Float( 0.2f, 2f ) + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 40f
: Position2D + Utils.GetRandomVector() * Game.Random.Float( 100f, 400f );
}
[Rpc.Broadcast]
protected void SpawnCracksRpc( Vector2 pos )
{
Manager.Instance.PlaySfxNearby( "spike.prepare", pos, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.85f, maxDist: 250f );
Manager.Instance.SpawnSpikerHeadCracksRpc( pos, _crackParticleColor );
Manager.Instance.SpawnSpikerHeadArea( pos, 30f, GetShootSpawnCracksDelay() + 0.15f );
}
//[Rpc.Broadcast]
//protected void ReverseShootingRpc()
//{
// SetAnim( "RPG_2H_Pose_Standing_Idle_01" );
//}
[Rpc.Broadcast]
public void StartDiggingRpc()
{
Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f, maxDist: 320f );
GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( 15f ) ) } );
SetAnim( "Dig" );
SetPlaybackRate( 1f );
CanAnimate = false;
}
[Rpc.Broadcast]
protected void FinishDiggingRpc( Vector2 pos )
{
Manager.Instance.PlaySfxNearby( "zombie.dirt", pos, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f, maxDist: 320f );
GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 15f ) ) } );
IsSpawning = true;
TimeSinceSpawn = 0f;
SpawnProgress = 0f;
ShouldSpawnInstantly = false;
PlaySpawnAnim();
CanAnimate = true;
SetPlaybackRate( 1f );
}
public override void OnStun()
{
base.OnStun();
PlayFlinchAnim();
SetState( SpikerState.Default );
}
protected override void Jump( Vector2 targetPos, float height, float lifetime )
{
SetState( SpikerState.Default );
base.Jump( targetPos, height, lifetime );
}
[Rpc.Broadcast]
public void EnterDefaultStateRpc()
{
CanAnimate = true;
PlayWalkAnim();
}
protected override void PlayFlinchAnim()
{
}
}