Enemy subclass for the Miniboss Zapper. Controls spawning values, movement/aggro parameters, a multi-segment visual laser beam, shooting and blinking (teleport) state machine, networked RPCs for starting/stopping effects, and player damage application.
using System;
using Sandbox;
public class MinibossZapper : Enemy
{
public override EnemyType EnemyType => EnemyType.MinibossZapper;
public override string GibFolder => "miniboss_zapper";
public override float OverrideGibChance => 1f;
public override int ExtraDeathBloodSprayAmount => 25;
protected override float MinibossHealthScale => 1.2f;
public override float GetMaxHealth() => MinibossBaseHealth * MinibossHealthScale;
[Property] public LineRenderer LaserLineRenderer { get; set; }
[Property] public GameObject LaserEndParticles { get; set; }
[Property] public Texture LaserTexture { get; set; }
protected virtual float LaserWobbleAmplitude => 5f;
protected virtual float LaserWobbleAmplitude2 => 2f;
protected virtual float LaserWobbleSpeedMin => 14f;
protected virtual float LaserWobbleSpeedMax => 22f;
private SceneLineObject _laserSo;
private float _laserWobbleRate;
private float _laserWobblePhase;
private float _laserScrollRate;
private const int LASER_NUM_SEGMENTS = 40;
public override Vector3 SpawnScale => new Vector3( 1.7f );
public override bool ShowHealthbar => true;
public override float HealthbarOffset => 100f;
public override float HealthbarOpacity => Utils.EasePercent( SpawnProgress, EasingType.QuadIn );
public override float HealthbarArmorOpacity => Utils.EasePercent( SpawnProgress, EasingType.QuadIn );
public override bool IsBoss => true;
public override bool IsMiniboss => true;
public override bool CanAttack => base.CanAttack && State == MinibossZapperState.Default;
public override bool CanMove => base.CanMove && State == MinibossZapperState.Default;
public override bool CanTurn => base.CanTurn && State == MinibossZapperState.Default;
public override bool CanBeStunned => base.CanBeStunned && State == MinibossZapperState.Default;
protected float _shootDelayTimer;
protected float _shootDelayMin;
protected float _shootDelayMax;
protected float _blinkDelayTimer;
protected float _blinkDelayMin;
protected float _blinkDelayMax;
protected float _blinkRange;
protected TimeSince _timeSinceBlinking;
protected float _blinkPrepareDelay;
protected float _laserTotalTime;
protected float _laserTotalTimeMin;
protected float _laserTotalTimeMax;
[Sync] public float LaserLength { get; set; }
protected float _laserLengthTotal;
public float LaserDamage { get; set; }
protected const float LASER_PREPARE_TIME = 1f;
protected TimeSince _timeSincePrepareShoot;
protected float _shootRange;
protected TimeSince _timeSinceShoot;
//private Particles _laserParticles;
//private Sound _laserLoopSfx;
protected const float LASER_HEIGHT = 70f;
protected const float LASER_TRACE_HEIGHT = 30f;
protected const float LASER_FORWARD_OFFSET = 74f;
public override float ParticleYPosOverride => 0.6f;
public override float StunParticleYPosOverride => 1.1f;
protected enum MinibossZapperState
{
Default,
ShootPrepare,
Shoot,
ShootFinish,
BlinkPrepare,
Blink,
BlinkFinish,
}
[Sync] protected MinibossZapperState State { get; private set; } = MinibossZapperState.Default;
protected override void OnDisabled()
{
base.OnDisabled();
_laserSo?.Delete();
_laserSo = null;
}
protected override void OnStart()
{
base.OnStart();
_laserWobbleRate = Game.Random.Float( LaserWobbleSpeedMin, LaserWobbleSpeedMax );
_laserWobblePhase = Game.Random.Float( 0f, MathF.PI * 2f );
_laserScrollRate = Game.Random.Float( 0.3f, 0.7f );
if ( !Application.IsDedicatedServer )
{
LaserLineRenderer.GameObject.Enabled = false;
_laserSo = new SceneLineObject( Scene.SceneWorld );
_laserSo.RenderingEnabled = false;
_laserSo.Flags.IsOpaque = false;
_laserSo.Flags.IsTranslucent = true;
_laserSo.Flags.CastShadows = true;
_laserSo.StartCap = SceneLineObject.CapStyle.Rounded;
_laserSo.EndCap = SceneLineObject.CapStyle.Rounded;
_laserSo.Face = SceneLineObject.FaceMode.Camera;
_laserSo.Material = Material.Load( "materials/shockwave.vmat" );
_laserSo.Attributes.Set( "g_flEmissiveScale", 5f );
_laserSo.Attributes.Set( "BaseTexture", LaserTexture ?? Texture.White );
}
CoinValueMin = 11;
CoinValueMax = 18;
CoinChance = 1f;
PushStrength = 7000f;
Weight = 1.2f;
_personalSpeedScale = 1f;
_personalSpeedFreq = Game.Random.Float( 9f, 11f );
// todo: never spawn foot gibs
if ( IsProxy )
return;
AggroRange = 50f;
DetectTargetRange = 375f;
LoseTargetRange = 800f;
LoseTargetTime = 6f;
MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 9f, 11f, 12f );
LaserDamage = Utils.Select( Manager.Instance.Difficulty, 6f, 10f, 13f );
DamageTargetDelay = 0.55f;
_personalTurnSpeed = Game.Random.Float( 3f, 5f );
Acceleration = 140f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
AccelerationAttacking = 160f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
Deceleration = 1.5f;
DecelerationAttacking = 1.4f;
_shootDelayMin = 2f;
_shootDelayMax = 3f;
_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
_shootRange = 600f;
_laserTotalTimeMin = 9f;
_laserTotalTimeMax = 12f;
_laserLengthTotal = 520f;
_blinkDelayMin = 5f;
_blinkDelayMax = 10f;
_blinkPrepareDelay = 0.9f;
_blinkRange = 1200f;
_blinkDelayTimer = Game.Random.Float( _blinkDelayMin, _blinkDelayMax );
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( Manager.Instance.IsGameOver )
return;
//Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"State: {State} _timeSinceShoot: {_timeSinceShoot} _laserTotalTime: {_laserTotalTime}", new global::Transform( WorldPosition ) );
if ( State == MinibossZapperState.ShootPrepare || State == MinibossZapperState.Shoot || State == MinibossZapperState.ShootFinish)
{
SetPlaybackRate( Utils.Map( _timeSincePrepareShoot, 0f, LASER_PREPARE_TIME, 0.1f, 1.5f ) );
if ( State == MinibossZapperState.Shoot )
{
Vector3 a = new Vector3( Position2D.x, Position2D.y, WorldPosition.z + LASER_HEIGHT ) + WorldRotation.Forward * LASER_FORWARD_OFFSET;
Vector3 b = a + WorldRotation.Forward * LaserLength;
if ( _laserSo is not null )
{
_laserSo.Transform = global::Transform.Zero;
var beamDir = (b - a);
float beamLength = beamDir.Length;
beamDir = beamDir.Normal;
var perp = new Vector3( -beamDir.y, beamDir.x, 0f );
float baseWidth = 2f + Utils.FastSin( _timeSincePrepareShoot * 8f ) * 1f;
Color laserColor = GetLaserColor();
_laserSo.StartLine();
for ( int i = 0; i <= LASER_NUM_SEGMENTS; i++ )
{
float t = (float)i / LASER_NUM_SEGMENTS;
Vector3 basePos = a + beamDir * (beamLength * t);
float envelope = MathF.Sin( t * MathF.PI );
float wobble = (
MathF.Sin( t * MathF.PI * 3f - Time.Now * _laserWobbleRate + _laserWobblePhase ) * LaserWobbleAmplitude
+ MathF.Sin( t * MathF.PI * 7f - Time.Now * _laserWobbleRate * 1.7f + _laserWobblePhase * 1.37f ) * LaserWobbleAmplitude2
) * envelope;
float widthWobble = MathF.Sin( t * MathF.PI * 5f - Time.Now * _laserWobbleRate * 2f + _laserWobblePhase * 1.5f ) * 0.5f;
float width = MathF.Max( baseWidth + widthWobble, 1.2f );
_laserSo.AddLinePoint( basePos + perp * wobble, Vector3.Up, laserColor, width, t - Time.Now * _laserScrollRate );
}
_laserSo.EndLine();
}
LaserEndParticles.WorldPosition = b;
}
}
if ( IsProxy )
return;
if ( !IsStunned && !IsDying )
HandleState();
}
protected override float GetMoveSpeedFactor()
{
return 1f;
}
protected void HandleState()
{
switch ( State )
{
case MinibossZapperState.Default:
if ( TargetUnit.IsValid() && !IsInTheAir )
{
var targetDistSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
if ( targetDistSqr < MathF.Pow( _shootRange, 2f ) )
{
_shootDelayTimer -= Time.Delta * TimeScale;
if ( _shootDelayTimer < 0f && targetDistSqr < MathF.Pow( _shootRange * 0.95f, 2f ) )
SetState( MinibossZapperState.ShootPrepare );
}
if ( targetDistSqr < MathF.Pow( _blinkRange, 2f ) )
{
_blinkDelayTimer -= Time.Delta * TimeScale;
if ( _blinkDelayTimer < 0f )
SetState( MinibossZapperState.BlinkPrepare );
}
}
break;
case MinibossZapperState.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( MinibossZapperState.Blink );
break;
case MinibossZapperState.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( MinibossZapperState.BlinkFinish );
break;
case MinibossZapperState.ShootPrepare:
if ( _timeSinceChangeState > LASER_PREPARE_TIME )
SetState( MinibossZapperState.Shoot );
break;
case MinibossZapperState.Shoot:
//Velocity *= (1f - Time.Delta * 6f * Manager.Instance.GlobalFrictionModifier);
Vector3 a = new Vector3( Position2D.x, Position2D.y, WorldPosition.z + LASER_TRACE_HEIGHT ) + WorldRotation.Forward * LASER_FORWARD_OFFSET;
Vector3 b = a + WorldRotation.Forward * LaserLength;
//Gizmo.Draw.Color = Color.Yellow.WithAlpha(0.2f);
//Gizmo.Draw.LineThickness = 2f;
//Gizmo.Draw.Line( a, b );
LaserLength = Utils.MapReturn( _timeSinceShoot, 0f, _laserTotalTime, 0f, _laserLengthTotal, EasingType.QuadOut );
// todo: sfx for laser
//_laserLoopSfx.SetVolume( Utils.MapReturn( _timeSincePrepareShoot, LASER_SHOOT_TIME, LASER_TIME_FINISH, 0.5f, 1f, EasingType.QuadOut ) * SS2Game.GLOBAL_VOLUME * SS2Game.SFX_VOLUME );
//_laserLoopSfx.SetPitch( Utils.MapReturn( _timeSincePrepareShoot, LASER_SHOOT_TIME, LASER_TIME_FINISH, 0.7f, 1f, EasingType.QuadOut ) );
if ( _timeSinceDamageTarget > (DamageTargetDelay / TimeScale) && !IsInTheAir )
{
var traceResults = Scene.Trace.Ray( a, b ).Radius( 2f ).HitTriggersOnly().WithAllTags( "player" ).RunAll();
foreach ( var tr in traceResults )
{
var player = tr.GameObject.GetComponent<Player>();
if ( player.IsValid() && !player.IsDead )
{
Vector2 dir = (player.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (player.Position2D - Position2D).Normal
: Utils.GetRandomVector();
var hitPos = player.Position2D + (Position2D - player.Position2D).Normal * player.Radius;
HurtPlayer( player, hitPos, dir );
_timeSinceDamageTarget = 0f;
}
}
}
//if ( SS2Game.Current.IsGameOver )
// FinishShooting();
HandleAiming();
if ( _timeSinceChangeState > _laserTotalTime )
SetState( MinibossZapperState.ShootFinish );
break;
}
}
protected void SetState( MinibossZapperState state )
{
State = state;
_timeSinceChangeState = 0f;
switch ( state )
{
case MinibossZapperState.Default:
EnterDefaultStateRpc();
break;
case MinibossZapperState.ShootPrepare:
StartShootingRpc();
_laserTotalTime = Game.Random.Float( _laserTotalTimeMin, _laserTotalTimeMax );
_shootDelayTimer = _laserTotalTime + Game.Random.Float( _shootDelayMin, _shootDelayMax );
break;
case MinibossZapperState.Shoot:
ShootRpc();
break;
case MinibossZapperState.ShootFinish:
FinishShootingRpc();
SetState( MinibossZapperState.Default );
break;
case MinibossZapperState.BlinkPrepare:
StartBlinkingRpc();
_timeSinceBlinking = 0f;
_blinkDelayTimer = Game.Random.Float( _blinkDelayMin, _blinkDelayMax );
break;
case MinibossZapperState.Blink:
var blinkPos = GetBlinkTargetPos();
BlinkRpc( WorldTransform, blinkPos );
break;
case MinibossZapperState.BlinkFinish:
SetState( MinibossZapperState.Default );
break;
}
}
protected virtual void HurtPlayer( Player player, Vector2 hitPos, Vector2 dir )
{
player.DamageRpc( LaserDamage, DamageType.Laser, hitPos, dir, upwardAmount: Game.Random.Float(0f, 0.1f), force: 50f, ragdollForce: 3f, this, enemyType: this.EnemyType );
}
protected virtual void HandleAiming()
{
float strength = Utils.MapReturn( _timeSinceShoot, 0f, _laserTotalTime, 10f, 150f, EasingType.QuadIn ) * Utils.Map( HpPercent, 1f, 0f, 0.5f, 1f );
Vector3 targetPos = (Vector3)TargetPos + new Vector3( Utils.FastSin( Time.Now * 8f ) * strength, Utils.FastSin( Time.Now * 11f ) * strength, 0f );
WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( (targetPos - WorldPosition).Normal.WithZ( 0f ) ), 1f * Time.Delta * TimeScale );
}
protected virtual Color GetLaserColor()
{
return Color.Yellow.WithAlpha( 5f + Utils.FastSin( _timeSincePrepareShoot * 15f ) * 4f );
}
[Rpc.Broadcast]
protected void StartShootingRpc()
{
CanAnimate = false;
_timeSincePrepareShoot = 0f;
PlayShootAnim();
Manager.Instance.PlaySfxNearby( "spitter.prepare", Position2D, pitch: Game.Random.Float( 0.9f, 0.95f ), volume: 0.6f, maxDist: 400f );
}
protected virtual void PlayShootAnim()
{
SetAnim( "ShootLoop" );
}
[Rpc.Broadcast]
protected void ShootRpc()
{
Shoot();
}
protected virtual void Shoot()
{
if ( _laserSo is not null )
_laserSo.RenderingEnabled = true;
LaserEndParticles.Enabled = true;
// todo: better sfx
//_laserLoopSfx = SS2Game.PlaySfx( "laser_loop0", this, 1f, volume: 0.95f );
Manager.Instance.PlaySfxNearby( "spitter.shoot", Position2D, pitch: Game.Random.Float( 0.9f, 0.95f ), volume: 0.85f, maxDist: 450f );
//SetAnim( "Attack3" );
//_laserParticles = SS2Game.CreateParticle( "particles/enemy/laser.vpcf", this );
//_laserParticles.Set( "Color", new Color( 0.8f, 0f, 0.15f ) );
_timeSinceShoot = 0f;
if ( IsProxy )
return;
}
[Rpc.Broadcast]
protected void FinishShootingRpc()
{
if ( _laserSo is not null )
_laserSo.RenderingEnabled = false;
LaserEndParticles.Enabled = false;
LaserEndParticles.WorldPosition = WorldPosition;
CanAnimate = true;
//SetPlaybackRate( 1f );
PlayWalkAnim();
}
protected virtual void FinishShooting()
{
if ( IsProxy )
return;
Velocity = Vector2.Zero;
}
public override void OnStun()
{
base.OnStun();
PlayFlinchAnim();
SetState( MinibossZapperState.Default );
}
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 );
}
[Rpc.Broadcast]
public void StartBlinkingRpc()
{
StartBlinking();
}
public void StartBlinking()
{
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/zapper_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;
}
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 );
}
}
[Rpc.Broadcast]
public void EnterDefaultStateRpc()
{
CanAnimate = true;
PlayWalkAnim();
WorldScale = new Vector3( 1f );
}
}