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).
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 );
}
}