things/enemies/MinibossZapper.cs

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.

NetworkingNative Interop
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 );
	}
}