Enemy AI component for the "Spitter" enemy. It defines movement, shooting and blink teleport behaviors, state machine transitions, animations, spawn/death effects and RPCs to broadcast visual/sound effects and projectile spawning.
using System;
using Sandbox;
public class Spitter : Enemy
{
[Property] public EnemyProjectileType ProjectileType { get; set; }
public override EnemyType EnemyType => EnemyType.Spitter;
public override float GetMaxHealth()
{
return 50f;
}
public override Vector3 SpawnScale => new Vector3( 1.05f );
protected float _shootDelayTimer;
protected float _shootDelayMin;
protected float _shootDelayMax;
protected float _prepareShootTime;
protected float _shootTime;
protected float _shootRange;
protected bool _isRetreating;
protected float _personalRetreatRange;
protected float _personalStopRetreatRange;
private Vector2 _personalTargetOffset;
protected float _blinkDelayTimer;
protected float _blinkDelayMin;
protected float _blinkDelayMax;
/// <summary>
/// Can blink if target is at least this close. Not the max distance to blink.
/// </summary>
protected float _blinkRange;
protected TimeSince _timeSinceBlinking;
protected Vector2 _blinkPos;
protected float _blinkPrepareDelay;
protected Vector2 _blinkTargetPos;
public override bool CanAttack => base.CanAttack && State == SpitterState.Default;
public override bool CanMove => base.CanMove && State == SpitterState.Default;
public override bool CanTurn => base.CanTurn && (State == SpitterState.Default || State == SpitterState.ShootPrepare || State == SpitterState.Shoot);
protected override bool ShouldRetreatFromTarget => IsFearful || (_isRetreating && !IsAttacking && !(State == SpitterState.ShootPrepare || State == SpitterState.Shoot));
protected enum SpitterState
{
Default,
ShootPrepare,
Shoot,
ShootFinish,
BlinkPrepare,
Blink,
BlinkFinish,
}
[Sync] protected SpitterState State { get; private set; } = SpitterState.Default;
protected override void OnStart()
{
base.OnStart();
CoinValueMin = 2;
CoinValueMax = 4;
CoinChance = 1f;
PushStrength = 6000f;
Weight = 1.2f;
_personalSpeedScale = Game.Random.Float( 1f, 1.1f );
_personalSpeedFreq = Game.Random.Float( 9f, 11f );
if ( IsProxy )
return;
AggroRange = 70f;
DetectTargetRange = 500f;
LoseTargetRange = 1100f;
LoseTargetTime = 5f;
MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 9f, 10f, 11f );
DamageTargetDelay = 0.75f;
_personalTurnSpeed = Game.Random.Float( 4f, 7f );
Acceleration = Utils.Select( Manager.Instance.Difficulty, 180f, 200f, 200f );
AccelerationAttacking = Utils.Select( Manager.Instance.Difficulty, 230f, 250f, 250f );
Deceleration = 2.2f;
DecelerationAttacking = 1.95f;
_shootDelayMin = 2f;
_shootDelayMax = 7f;
_shootRange = Utils.Select( Manager.Instance.Difficulty, 480f, 500f, 650f );
_prepareShootTime = 0.9f;
_shootTime = 0.6f;
_blinkDelayMin = 5f;
_blinkDelayMax = 10f;
_blinkPrepareDelay = 0.9f;
_blinkRange = 700f;
_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
_blinkDelayTimer = Game.Random.Float( _blinkDelayMin, _blinkDelayMax );
_personalRetreatRange = Game.Random.Float( 185f, 250f );
_personalStopRetreatRange = Game.Random.Float( 300f, 370f );
_personalTargetOffset = new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * Game.Random.Float( 150f, 450f );
}
//public void SetProjectileType( EnemyProjectileType projectileType )
//{
// ProjectileType = projectileType;
// GameObject particleObject = null;
// switch(projectileType)
// {
// case EnemyProjectileType.Normal:
// default:
// break;
// case EnemyProjectileType.Acid:
// particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_acid.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
// break;
// case EnemyProjectileType.Burning:
// particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_fire.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
// break;
// case EnemyProjectileType.Freezing:
// particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_freeze.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
// break;
// case EnemyProjectileType.Poison:
// particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_poison.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
// break;
// case EnemyProjectileType.Curse:
// particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_curse.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
// break;
// }
// if ( particleObject.IsValid() )
// particleObject.LocalPosition = new Vector3( 0f, 0f, 40f );
//}
protected override void OnUpdate()
{
base.OnUpdate();
//Gizmo.Draw.Color = Color.White.WithAlpha(0.1f);
//Gizmo.Draw.Text( $"\n\n{ProjectileType}", new global::Transform( WorldPosition ) );
if ( Manager.Instance.IsGameOver )
return;
if ( IsProxy )
return;
if ( !IsStunned && !IsDying )
HandleState();
}
protected void HandleState()
{
switch ( State )
{
case SpitterState.Default:
if ( HasTarget )
{
var distSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
if ( _isRetreating && distSqr > MathF.Pow( _personalStopRetreatRange, 2f ) )
_isRetreating = false;
else if ( !_isRetreating && distSqr < MathF.Pow( _personalRetreatRange, 2f ) )
_isRetreating = true;
}
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 && targetDistSqr < MathF.Pow( _shootRange * 0.85f, 2f ) )
{
BeginShooting();
SetState( SpitterState.ShootPrepare );
}
}
if ( targetDistSqr < MathF.Pow( _blinkRange, 2f ) )
{
_blinkDelayTimer -= Time.Delta * TimeScale;
if ( _blinkDelayTimer < 0f )
SetState( SpitterState.BlinkPrepare );
}
}
break;
case SpitterState.ShootPrepare:
if ( _timeSinceChangeState > _prepareShootTime )
SetState( SpitterState.Shoot );
break;
case SpitterState.Shoot:
HandleStateShoot();
break;
case SpitterState.BlinkPrepare:
var blinkPrepareScale = Vector3.Lerp( new Vector3( 1f ), new Vector3( 1f, 1f, 1.3f ), Utils.Map( _timeSinceChangeState, 0f, _blinkPrepareDelay, 0f, 1f, EasingType.ExpoIn ) );
WorldScale = blinkPrepareScale;
if ( _timeSinceChangeState > _blinkPrepareDelay )
SetState( SpitterState.Blink );
break;
case SpitterState.Blink:
var blinkFinishScale = Vector3.Lerp( new Vector3( 1f, 1f, 1.3f ), new Vector3( 1f ), Utils.Map( _timeSinceChangeState, 0f, 0.5f, 0f, 1f, EasingType.QuadOut ) );
WorldScale = blinkFinishScale;
if ( _timeSinceChangeState > 0.5f )
SetState( SpitterState.BlinkFinish );
break;
}
}
protected void SetState( SpitterState state )
{
State = state;
_timeSinceChangeState = 0f;
switch ( state )
{
case SpitterState.Default:
EnterDefaultStateRpc();
break;
case SpitterState.ShootPrepare:
SetStateShootPrepare();
break;
case SpitterState.Shoot:
SetStateShoot();
break;
case SpitterState.ShootFinish:
Velocity = Vector2.Zero;
SetState( SpitterState.Default );
break;
case SpitterState.BlinkPrepare:
StartBlinkingRpc();
_timeSinceBlinking = 0f;
_blinkDelayTimer = Game.Random.Float( _blinkDelayMin, _blinkDelayMax );
break;
case SpitterState.Blink:
var blinkPos = GetBlinkTargetPos();
BlinkRpc( WorldTransform, blinkPos );
break;
case SpitterState.BlinkFinish:
SetState( SpitterState.Default );
break;
}
}
protected virtual void BeginShooting()
{
}
protected virtual void SetStateShootPrepare()
{
StartShootingRpc();
_isRetreating = false;
_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
_personalTargetOffset = new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * Game.Random.Float( 150f, 450f );
}
protected virtual void SetStateShoot()
{
ShootRpc();
}
protected virtual void HandleStateShoot()
{
if ( _timeSinceChangeState > _shootTime )
SetState( SpitterState.ShootFinish );
}
protected virtual Vector2 GetBlinkTargetPos()
{
Vector2 blinkPos = TargetUnit.IsValid()
? TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 1.5f ) + Utils.GetRandomVector() * Game.Random.Float( 200f, 400f )
: Position2D + Utils.GetRandomVector() * Game.Random.Float(200f, 400f);
return Manager.Instance.ClampPosToBounds( blinkPos );
}
protected override float GetMoveSpeedFactor()
{
var leftFootStart = 0.35f;
var leftFootEnd = 0.55f;
var rightFootStart = 0.55f;
var rightFootEnd = 0.80f;
var leftFootStart2 = 0.80f;
var leftFootEnd2 = 1.0f;
var rightFootStart2 = 0f;
var rightFootEnd2 = 0.35f;
var progress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;
if ( progress > leftFootStart && progress < leftFootEnd )
return Utils.Map( progress, leftFootStart, leftFootEnd, 0f, 1f, EasingType.Linear );
else if ( progress > leftFootStart2 && progress < leftFootEnd2 )
return Utils.Map( progress, leftFootStart2, leftFootEnd2, 0f, 1f, EasingType.Linear );
else if ( progress > rightFootStart && progress < rightFootEnd )
return Utils.Map( progress, rightFootStart, rightFootEnd, 0f, 1f, EasingType.Linear );
else if ( progress > rightFootStart2 && progress < rightFootEnd2 )
return Utils.Map( progress, rightFootStart2, rightFootEnd2, 0f, 1f, EasingType.Linear );
return 0f;
}
protected override Vector2 GetTargetOffset()
{
var dist = (TargetUnit.Position2D - Position2D).Length;
var offset = TargetUnit.Velocity * (0.5f + Utils.FastSin( TimeSinceSpawn * 3f ) * 0.5f) * dist * 0.012f;
// don't lead shots if target is very close and heading toward us
if( dist < 110f )
{
var dot = Vector2.Dot( (Position2D - TargetUnit.Position2D).Normal, TargetUnit.Velocity.Normal );
if ( dot > 0.65f )
offset = Vector2.Zero;
}
if ( State == SpitterState.Default && !_isRetreating )
offset += _personalTargetOffset + new Vector2( Utils.FastSin( TimeSinceSpawn * 0.7f ), Utils.FastSin( TimeSinceSpawn * 1.1f ) ) * 50f;
return offset;
}
protected virtual void PlayShootAnim()
{
SetAnim( "Shoot", forceRestart: true );
//SetAnim( Game.Random.Float( 0f, 1f ) < 0.5f ? "HoldItem_RH_Throw_Normal" : "HoldItem_LH_Throw_Normal" );
//SetPlaybackRate( 0.5f );
}
protected override void LoseTarget()
{
base.LoseTarget();
_isRetreating = false;
}
[Rpc.Broadcast]
protected void StartShootingRpc()
{
StartShooting();
}
protected virtual void StartShooting()
{
CanAnimate = false;
SetPlaybackRate( 1f );
PlayShootAnim();
Manager.Instance.PlaySfxNearby( "spitter.prepare", Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.6f, maxDist: 400f );
}
[Rpc.Broadcast]
protected void ShootRpc()
{
Shoot();
}
protected virtual void Shoot()
{
Manager.Instance.PlaySfxNearby( "spitter.shoot", Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.85f, maxDist: 450f );
if ( IsProxy )
return;
var dir = (Vector2)WorldRotation.Forward;
var pos = Position2D + dir * 45f;
Manager.Instance.SpawnEnemyProjectile( pos, dir, shooter: this, enemyType: this.EnemyType, startVel: 150f, projectileType: ProjectileType );
}
[Rpc.Broadcast]
public void StartBlinkingRpc()
{
StartBlinking();
}
public void StartBlinking()
{
//SS2Game.PlaySfx("spitter.prepare", Position, pitch: Game.Random.Float(1f, 1.1f), volume: 0.8f);
//SS2Game.SpawnRing( pos, 25f, BLINK_PREPARE_DELAY, new Color( 0.7f, 0f, 1f, 0.9f ) );
CanAnimate = false;
SetAnim( "Blink" );
SetPlaybackRate( 0.7f );
}
[Rpc.Broadcast(NetFlags.Reliable)]
protected void BlinkRpc( Transform sourceTransform, Vector2 pos )
{
Blink( sourceTransform, pos );
}
protected virtual void Blink( Transform sourceTransform, Vector2 pos )
{
var blinkEffectObj = GameObject.Clone( "prefabs/effects/spitter_blink_effect.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform(sourceTransform.Position, sourceTransform.Rotation, sourceTransform.Scale * SpawnScale.x) } );
if( blinkEffectObj.IsValid() )
{
SpitterBlinkEffect blinkEffect = blinkEffectObj.GetComponent<SpitterBlinkEffect>();
blinkEffect.ModelRenderer.SceneModel.CurrentSequence.Name = "Blink";
blinkEffect.AnimTime = ModelRenderer.SceneModel.CurrentSequence.Time;
}
//GameObject.Clone( "prefabs/effects/spitter_blink_particles.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( 30f ) ) } );
//GameObject.Clone( "prefabs/effects/spitter_blink_particles.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 30f ) ) } );
var numCloudsStart = Game.Random.Int( 5, 9 );
for ( int i = 0; i < numCloudsStart; i++ )
{
var cloudPos = WorldPosition.WithZ( Game.Random.Float( 7f, 12f ) );
var dir = Utils.GetRandomVector();
var deceleration = 4f;
Manager.Instance.SpawnCloud( cloudPos + (Vector3)dir * Game.Random.Float( 30f, 45f ), velocity: -dir * Game.Random.Float( 90f, 155f ), deceleration, lifetime: Game.Random.Float( 0.5f, 0.8f ), bright: true );
}
Manager.Instance.PlaySfxNearby( "blink.start", Position2D, pitch: Game.Random.Float( 1.1f, 1.2f ), volume: 0.3f, maxDist: 400f );
Manager.Instance.PlaySfxNearby( "blink.end", pos, pitch: Game.Random.Float( 1.1f, 1.2f ), volume: 0.5f, maxDist: 400f );
SetPlaybackRate( 1.2f );
WorldPosition = new Vector3( pos.x, pos.y, 0f );
Transform.ClearInterpolation();
var numCloudsEnd = Game.Random.Int( 5, 9 );
for (int i = 0; i < numCloudsEnd; i++)
{
var cloudPos = new Vector3( pos.x, pos.y, Game.Random.Float( 7f, 12f ) );
var dir = Utils.GetRandomVector();
var deceleration = 4f;
Manager.Instance.SpawnCloud( cloudPos + (Vector3)dir * Game.Random.Float( 0.1f, 5f ), velocity: dir * Game.Random.Float( 160f, 300f ), deceleration, lifetime: Game.Random.Float( 0.5f, 2f ), bright: true );
}
}
public override void OnStun()
{
base.OnStun();
PlayFlinchAnim();
SetState( SpitterState.Default );
}
public override void Die( Vector2 dir, float force, Player player, DamageType damageType )
{
base.Die( dir, force, player, damageType );
switch ( ProjectileType )
{
case EnemyProjectileType.Normal:
default:
break;
case EnemyProjectileType.Acid:
Manager.Instance.PlaySfxNearbyRpc( "puddle_splat", Position2D, pitch: Game.Random.Float( 1.05f, 1.1f ), volume: 1.2f, maxDist: 350f );
var acidDmg = Utils.Select( Manager.Instance.Difficulty, 3f, 5f, 7f );
Manager.Instance.SpawnAcidPuddle( Position2D, lifetime: Game.Random.Float( 8f, 10f ), acidDmg, scale: Game.Random.Float( 0.8f, 1.1f ), Color.Yellow, new Color( 0.5f, 0.5f, 0f ), playerSource: null, enemySource: null, enemyType: this.EnemyType );
break;
case EnemyProjectileType.Fire:
var fireDmg = Utils.Select( Manager.Instance.Difficulty, 3f, 5f, 6f );
var spreadChance = Utils.Select( Manager.Instance.Difficulty, 0.25f, 0.35f, 0.45f );
Manager.Instance.SpawnFireGroundRpc( Position2D, player: null, enemySource: null, enemyType: this.EnemyType, damage: fireDmg, lifetime: Game.Random.Float( 10f, 12f ), spreadChance: spreadChance, canStack: false, scale: 1f, colorA: Color.Magenta, colorB: Color.Red, hurtPlayers: true, hurtEnemies: false );
// todo: sfx
break;
case EnemyProjectileType.Freeze:
break;
case EnemyProjectileType.Poison:
break;
}
}
[Rpc.Broadcast]
public void EnterDefaultStateRpc()
{
CanAnimate = true;
PlayWalkAnim();
WorldScale = new Vector3( 1f );
}
protected override void Jump( Vector2 targetPos, float height, float lifetime )
{
SetState( SpitterState.Default );
base.Jump( targetPos, height, lifetime );
}
protected override void SpawnGibs( Vector2 dir, float force, DamageType damageType )
{
base.SpawnGibs( dir, force, damageType );
// todo: don't spawn head gib if shooting, or might have 2 heads
}
}