things/enemies/ChestEvil.cs

Enemy Chest subclass 'ChestEvil'. Implements behavior for an evil chest enemy: health, timed invisibility/flicker, despawn after lifetime, play sfx and VFX, handle damage and death rewards, and spawn assorted hazard patterns (rings of enemies/projectiles/items).

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Numerics;
using Sandbox;

public class ChestEvil : Chest
{
	public override EnemyType EnemyType => EnemyType.ChestEvil;
	public override float GetMaxHealth()
	{
		return 95f;
	}


	private float _evilTimer;
	private const float EVIL_LIFETIME = 35f;
	private bool _isDespawning;
	private TimeSince _timeSinceDespawn;
	private const float DESPAWN_TIME = 1.5f;

	public bool IsVisible { get; private set; }
	private TimeSince _timeSinceFlicker;
	public virtual float BlinkTimeRemainingStart => 5f;

	protected override void OnStart()
	{
		base.OnStart();

		Manager.Instance.PlaySfxNearby( "evil_chest", Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 0.75f, maxDist: 4000f );
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"_evilTimer: {_evilTimer}", new global::Transform( WorldPosition ) );

		_evilTimer += Time.Delta;
		if ( _evilTimer > EVIL_LIFETIME - BlinkTimeRemainingStart && _evilTimer < EVIL_LIFETIME )
		{
			float delay = Utils.Map( _evilTimer, EVIL_LIFETIME - BlinkTimeRemainingStart, EVIL_LIFETIME, 0.125f, 0.025f, EasingType.QuadIn );
			if ( _timeSinceFlicker > delay )
			{
				SetVisible( !IsVisible );
				_timeSinceFlicker = 0f;
			}
		}

		if( !IsVisible && _evilTimer > EVIL_LIFETIME )
			SetVisible( true );

		if ( IsProxy )
			return;

		if ( _isDespawning )
		{
			var zPos = Utils.Map( _timeSinceDespawn, 0f, DESPAWN_TIME, 0f, SpawnZPos );
			WorldPosition = WorldPosition.WithZ( zPos );

			if ( _timeSinceDespawn > DESPAWN_TIME )
			{
				Remove();
				return;
			}
		}

		if ( !_isDespawning && _evilTimer > EVIL_LIFETIME )
		{
			_isDespawning = true;
			_timeSinceDespawn = 0f;

			DespawnVfx();
		}
	}

	[Rpc.Broadcast]
	public void DespawnVfx()
	{
		GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( 15f ) ) } );
		Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.5f, 0.6f ), volume: 0.8f, maxDist: 380f );
	}

	protected override void Damage( float damage, Player player, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool shouldFlinch = true, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		base.Damage( damage, player, damageType, hitPos, force, isCrit, shouldFlinch, damageFlags );

		if ( !IsVisible )
			SetVisible( true );

		_evilTimer = Math.Max( 0f, _evilTimer -= 5f );

		if ( IsProxy )
			return;

	}

	public virtual void SetVisible( bool visible )
	{
		IsVisible = visible;
		//ModelRenderer.Enabled = visible;
		ModelRenderer.Tint = ModelRenderer.Tint.WithAlpha( visible ? 1f : 0f );
	}

	protected override void StartDying( Vector2 dir, float force, Player player, DamageType damageType )
	{
		base.StartDying( dir, force, player, damageType );

		_killingPlayer = player;
	}

	public static void SpawnHazard( Vector2 playerPos, Vector2 chestPos, Enemy enemySource, EnemyType enemyType )
	{
		int rand = Game.Random.Int( 0, 9 );
		switch ( rand )
		{
			case 0: // fire ring
				Manager.Instance.SpawnFireRing( playerPos, 160f, 16, damage: 7f, lifetime: Game.Random.Float( 10f, 12f ), startDegrees: 0f, scale: 1f, Color.Magenta, Color.Red, playerSource: null, enemySource: enemySource, enemyType: enemyType, hurtPlayers: true, hurtEnemies: false, spreadChance: 0.5f );
				break;
			case 1: // tree ring
				Manager.Instance.SpawnEnemyRingRpc( EnemyType.Tree, playerPos, 80f, 8 );
				break;
			case 2: // zombie ring
				Manager.Instance.SpawnEnemyRingRpc( EnemyType.Zombie, playerPos, 140f, 24 );
				break;
			case 3: // wolf ring
				Manager.Instance.SpawnEnemyRingRpc( EnemyType.Runner, playerPos, 140f, 3 );
				break;
			case 4: // explosive barrel + fire rings
				float degrees = Game.Random.Float( 0f, 360f );
				Manager.Instance.SpawnFireRing( playerPos, 120f, 3, damage: 4f, lifetime: Game.Random.Float( 8f, 10f ), degrees, scale: 1f, Color.Red, Color.Yellow, playerSource: null, enemySource: enemySource, enemyType: enemyType, spreadChance: 0.05f );
				Manager.Instance.SpawnEnemyRingRpc( EnemyType.BarrelExploding, playerPos, 120f, 3, degrees ); // todo: should killing player be considered creator of the explosions from barrels?
				degrees += 180f;
				Manager.Instance.SpawnFireRing( playerPos, 170f, 3, damage: 4f, lifetime: Game.Random.Float( 8f, 10f ), degrees, scale: 1f, Color.Red, Color.Yellow, playerSource: null, enemySource: enemySource, enemyType: enemyType, spreadChance: 0.05f );
				Manager.Instance.SpawnEnemyRingRpc( EnemyType.BarrelExploding, playerPos, 170f, 3, degrees );
				break;			
			case 5: // bomb ring
				Manager.Instance.SpawnBombRingRpc( playerPos, Game.Random.Float( 110f, 175f ), Game.Random.Int( 5, 8 ) );
				break;
			case 6: // landmine ring
				Manager.Instance.SpawnLandmineRingRpc( playerPos, Game.Random.Float( 90f, 140f ), Game.Random.Int( 5, 9 ) );
				break;
			case 7: // acid puddle rings
				float currAngle = Game.Random.Float( 0f, 360f );
				Manager.Instance.SpawnAcidPuddleRingRpc( playerPos, radius: 90f, num: Game.Random.Int( 5, 6 ), scale: Game.Random.Float( 1.1f, 1.25f ), lifetime: Game.Random.Float( 5f, 5.5f ), damage: 8f, playerSource: null, enemySource: enemySource, enemyType: enemyType, currAngle );

				currAngle += Game.Random.Float( 90f, 110f );
				Manager.Instance.SpawnAcidPuddleRingRpc( playerPos, radius: Game.Random.Float( 160f, 190f ), num: Game.Random.Int( 6, 7 ), scale: Game.Random.Float( 1.35f, 1.45f ), lifetime: Game.Random.Float( 6f, 6.5f ), damage: 8f, playerSource: null, enemySource: enemySource, enemyType: enemyType, currAngle );

				currAngle += Game.Random.Float( 50f, 70f );
				Manager.Instance.SpawnAcidPuddleRingRpc( playerPos, radius: Game.Random.Float( 240f, 290f ), num: Game.Random.Int( 8, 9 ), scale: Game.Random.Float( 1.55f, 1.65f ), lifetime: Game.Random.Float( 7f, 7.5f ), damage: 8f, playerSource: null, enemySource: enemySource, enemyType: enemyType, currAngle );
				break;
			case 8: // projectile ring
				SpawnProjectileRingHazard( chestPos, enemySource, enemyType );
				break;
			case 9: // homing skulls
				SpawnHomingProjectileRingHazard( chestPos, enemySource, enemyType );
				break;

			// todo: launch lava blobs

			// todo: swords fall

			// todo: mushrooms

			// todo: shockwave
		}
	}

	protected override void PlayOpenEffects()
	{
		Manager.Instance.PlaySfxNearby( "chest.open", Position2D, pitch: Game.Random.Float( 0.95f, 1.1f ), volume: 1.3f, maxDist: 450f );
		Manager.Instance.PlaySfxNearby( "heavenly", Position2D, pitch: Game.Random.Float( 0.8f, 0.85f ), volume: 0.8f, maxDist: 450f );
	}

	protected override void DropReward()
	{
		var dropDir = _killingPlayer.IsValid() ? (_killingPlayer.Position2D - Position2D).Normal : Utils.GetRandomVector();

		var pos = Position2D + Utils.GetRandomVectorInCone( dropDir, coneDegrees: 220f ) * Game.Random.Float( 1f, 8f );
		var dir = (pos - Position2D).Normal;
		var numDeadPlayers = Manager.Instance.Players.Count - Manager.Instance.AlivePlayers.Count;
		var needsSoul = _numSoulsSpawned < numDeadPlayers;

		// Force soul if needed and past reward threshold
		if ( needsSoul && _numRewardsGiven > 8 )
		{
			Manager.Instance.SpawnItem( "revive_soul", pos, dir );
			_numSoulsSpawned++;
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 2f, 2.2f ), volume: 1.05f, maxDist: 300f );
			return;
		}

		var rewards = new List<(float weight, Action action)>();

		// Soul gets 15% when needed, otherwise coins absorbs that probability
		if ( needsSoul )
		{
			rewards.Add( (15f, () => {
				Manager.Instance.SpawnItem( "revive_soul", pos, dir );
				_numSoulsSpawned++;
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 2f, 2.2f ), volume: 1.05f, maxDist: 300f );
			}) );
			rewards.Add( (35f, () => {
				Manager.Instance.SpawnCoin( pos, Game.Random.Int( 1, 8 ), dir );
				Manager.Instance.PlaySfxNearbyRpc( "chaching", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1f, 1.2f ), volume: 0.85f, maxDist: 300f );
			}) );
		}
		else
		{
			rewards.Add( (50f, () => {
				Manager.Instance.SpawnCoin( pos, Game.Random.Int( 1, 8 ), dir );
				Manager.Instance.PlaySfxNearbyRpc( "chaching", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1f, 1.2f ), volume: 0.85f, maxDist: 300f );
			}) );
		}

		rewards.Add( (20f, () => {
			if ( _killingPlayer.IsValid() )
				_killingPlayer.GiveRandomPerkItemRpc( pos, dir, Rarity.None, isReward: true );
			else
				Manager.Instance.SpawnItem( "banish_item", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1.4f, 1.7f ), volume: 1.1f, maxDist: 300f );
		}) );
		rewards.Add( (20f, () => {
			Manager.Instance.SpawnItem( "banish_item", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 0.8f, 0.9f ), volume: 0.85f, maxDist: 300f );
		}) );
		rewards.Add( (10f, () => {
			Manager.Instance.SpawnItem( "bomb", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 0.7f, 0.8f ), volume: 0.85f, maxDist: 300f );
		}) );

		float totalWeight = 0f;
		foreach ( var (weight, _) in rewards )
			totalWeight += weight;

		float rand = Game.Random.Float( 0f, totalWeight );
		float cumulative = 0f;

		foreach ( var (weight, action) in rewards )
		{
			cumulative += weight;
			if ( rand < cumulative )
			{
				action();
				return;
			}
		}

		rewards[^1].action();
	}

	public override void Die( Vector2 dir, float force, Player player, DamageType damageType )
	{
		base.Die( dir, force, player, damageType );

		if ( IsProxy )
			return;

		Manager.Instance.SpawnMiniboss( Position2D );

		if ( _killingPlayer.IsValid() )
			SpawnHazard( _killingPlayer.Position2D, Position2D, this, this.EnemyType );
	}

	public static void SpawnProjectileRingHazard( Vector2 chestPos, Enemy enemySource, EnemyType enemyType )
	{
		var projectileWeights = new Dictionary<EnemyProjectileType, float>
		{
			{ EnemyProjectileType.Normal, 1.0f },
			{ EnemyProjectileType.Acid, 0.8f },
			{ EnemyProjectileType.Fire, 0.75f },
			{ EnemyProjectileType.Freeze, 0.75f },
			{ EnemyProjectileType.Poison, 0.5f }
		};

		if ( Manager.Instance.Difficulty > 0 )
			projectileWeights.Add( EnemyProjectileType.Curse, 0.25f );

		EnemyProjectileType chosenProjectileType = Utils.GetWeightedRandom( projectileWeights );

		var startVelMin = 300f;
		var startVelMax = 300f;
		var lifetimeModMin = 1f;
		var lifetimeModMax = 1f;

		var numProjectiles = Game.Random.Int( 7, 12 );

		switch ( chosenProjectileType )
		{
			case EnemyProjectileType.Acid:
				startVelMin = startVelMax = 320f;
				break;
			case EnemyProjectileType.Fire:
				startVelMin = startVelMax = 270f;
				numProjectiles = Game.Random.Int( 5, 9 );
				break;
			case EnemyProjectileType.Freeze:
				startVelMin = 240f;
				startVelMax = 320f;
				lifetimeModMin = 0.1f;
				lifetimeModMax = 0.5f;
				numProjectiles = Game.Random.Int( 6, 10 );
				break;
			case EnemyProjectileType.Poison:
				startVelMin = 250f;
				startVelMax = 300f;
				lifetimeModMin = 0.8f;
				lifetimeModMax = 1.1f;
				break;
			case EnemyProjectileType.Curse:
				startVelMin = startVelMax = 220f;
				lifetimeModMin = 0.7f;
				lifetimeModMax = 1f;
				numProjectiles = Game.Random.Int( 3, 6 );
				break;
		}

		if( Manager.Instance.Difficulty > 0 )
		{
			numProjectiles += Game.Random.Int( 2, 4 );
		}

		Manager.Instance.SpawnEnemyProjectileRing( chestPos, numProjectiles, shooter: enemySource, enemyType: enemyType, startVelMin, startVelMax, lifetimeModMin, lifetimeModMax, chosenProjectileType, zPos: 40f );
	}

	public static void SpawnHomingProjectileRingHazard( Vector2 chestPos, Enemy enemySource, EnemyType enemyType )
	{
		var projectileWeights = new Dictionary<EnemyProjectileType, float>
		{
			{ EnemyProjectileType.Normal, 1.0f },
			{ EnemyProjectileType.Acid, 0.8f },
			{ EnemyProjectileType.Fire, 0.75f },
			{ EnemyProjectileType.Freeze, 0.75f },
			{ EnemyProjectileType.Poison, 0.5f }
		};

		if ( Manager.Instance.Difficulty > 0 )
			projectileWeights.Add( EnemyProjectileType.Curse, 0.2f );

		EnemyProjectileType chosenProjectileType = Utils.GetWeightedRandom( projectileWeights );

		var startVelMin = 200f;
		var startVelMax = 200f;
		var lifetimeModMin = 0.95f;
		var lifetimeModMax = 1.1f;

		var numProjectiles = Game.Random.Int( 3, 6 );

		switch ( chosenProjectileType )
		{
			case EnemyProjectileType.Acid:
				startVelMin = startVelMax = 220f;
				break;
			case EnemyProjectileType.Fire:
				startVelMin = startVelMax = 180f;
				numProjectiles = Game.Random.Int( 3, 5 );
				lifetimeModMin = 0.7f;
				lifetimeModMax = 0.9f;
				break;
			case EnemyProjectileType.Freeze:
				startVelMin = 140f;
				startVelMax = 200f;
				lifetimeModMin = 0.1f;
				lifetimeModMax = 0.5f;
				numProjectiles = Game.Random.Int( 2, 5 );
				break;
			case EnemyProjectileType.Poison:
				startVelMin = 250f;
				startVelMax = 300f;
				lifetimeModMin = 0.8f;
				lifetimeModMax = 1.1f;
				break;
			case EnemyProjectileType.Curse:
				startVelMin = startVelMax = 150f;
				lifetimeModMin = 0.6f;
				lifetimeModMax = 0.8f;
				numProjectiles = Game.Random.Int( 2, 3 );
				break;
		}

		if ( Manager.Instance.Difficulty > 0 )
		{
			lifetimeModMin *= 1.75f;
			lifetimeModMax *= 1.75f;
		}

		Manager.Instance.SpawnEnemyHomingProjectileRing( chestPos, numProjectiles, shooter: enemySource, enemyType: enemyType, startVelMin, startVelMax, lifetimeModMin, lifetimeModMax, chosenProjectileType, zPos: 40f );
	}
}