Enemy AI component for the Spitter enemy. Controls health, movement parameters, shooting and blink (teleport) behavior, animation states, RPCs for networked actions, and death effects based on projectile type.
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
}
}