things/enemies/Chest.cs

Enemy subclass representing a loot chest. It is non-moving, non-attacking, opens when damaged to play effects and spawn rewards (coins, items, perks, souls) with weighted random selection and spawns gibs/effects on destruction.

NetworkingFile Access
using Sandbox;
using System;

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

	public override bool CanHaveTarget => false;
	public override bool CanAttack => false;
	public override bool CanTurn => false;
	//public override bool CanBeBackstabbed => false;
	public override bool CountsAsKill => false;
	public override bool CanMove => false;
	public override bool IsInanimate => true;
	public override bool CanBeTargeted => false;
	public override bool CanHitstop => false;

	protected string _debrisName;
	public override string GibFolder => "wood";
	public override float OverrideGibChance => 1f;

	protected float _dyingTimer;
	private const float DYING_TIME = 2.5f;
	protected int _numRewardsGiven;
	protected int _numSoulsSpawned;

	protected Player _killingPlayer;

	protected TimeSince _timeSinceReward;

	private bool _loggedStuckSpawning;

	public override float ParticleYPosOverride => 0.7f;
	public override bool CanCombust => false;


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

		CoinValueMin = 0;
		CoinValueMax = 0;
		CoinChance = 0f;

		PushStrength = 10000f;
		Weight = 2.2f;

		_debrisName = "barrel_debris";

		// Chest lifecycle diagnostics for the "miniboss dropped no chest" reports: logged on every machine
		// (IsProxy distinguishes host/client) so a chest that exists but is never seen can be traced
		Log.Info( $"[ChestSpawn] {EnemyType} OnStart at {Position2D} (IsProxy={IsProxy}, tintAlpha={ModelRenderer.Tint.a:F2})" );

		if ( IsProxy )
			return;

		Deceleration = 4.2f;
	}

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

		Log.Info( $"[ChestSpawn] {EnemyType} finished spawning at {Position2D} (IsProxy={IsProxy})" );
	}

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

		Log.Info( $"[ChestSpawn] {EnemyType} destroyed {TimeSinceSpawn.Relative:F1}s after spawn — opened={IsDying} rewardsGiven={_numRewardsGiven} pos={WorldPosition} (IsProxy={IsProxy})" );
	}

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

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{SceneModel.CurrentSequence.Name}\n{ModelRenderer.PlaybackRate}\n_dyingTimer: {_dyingTimer}\n_numRewardsGiven: {_numRewardsGiven}", new global::Transform( WorldPosition ) );

		if ( IsSpawning )
		{
			if ( !_loggedStuckSpawning && TimeSinceSpawn > 30f )
			{
				_loggedStuckSpawning = true;
				Log.Warning( $"[ChestSpawn] {EnemyType} still spawning {TimeSinceSpawn.Relative:F1}s after spawn! pos={WorldPosition} IsDying={IsDying} (IsProxy={IsProxy})" );
			}

			return;
		}

		if( IsDying && _numRewardsGiven > 0 )
		{
			var scaleOffset = 0.15f * Utils.MapReturn( _timeSinceReward, 0f, Utils.Map( _numRewardsGiven, 0, 8, 0.25f, 0.15f, EasingType.Linear ), 0f, 1f, EasingType.Linear );
			WorldScale = new Vector3( SpawnScale.x - scaleOffset, SpawnScale.y - scaleOffset, SpawnScale.z + scaleOffset );
		}

		if ( IsProxy )
			return;

		if ( IsDying )
		{
			_dyingTimer += Time.Delta;
			if ( _dyingTimer > DYING_TIME )
			{
				DieRpc( Utils.GetRandomVector(), force: 0f, _killingPlayer, DamageType.Other );
			}

			if ( _dyingTimer > 0.5f && _dyingTimer < DYING_TIME * 0.75f )
			{
				if ( _timeSinceReward > Utils.Map( _numRewardsGiven, 0, 7, 0.25f, 0.1f, EasingType.Linear ) )
				{
					DropRewardRpc();
				}
			}

			if ( _dyingTimer > DYING_TIME * 0.885f )
				SetPlaybackRate( 4f );
			else if ( _dyingTimer > 0.5f )
				SetPlaybackRate( 0.75f );
		}

		if ( IsDying )
			return;

		if ( Manager.Instance.IsWindActive )
			Velocity += (Manager.Instance.GlobalWindForce / (Weight * 3f)) * Time.Delta;

		Velocity *= Math.Max( 1f - Time.Delta * Deceleration * Manager.Instance.GlobalFrictionModifier, 0f );

		WorldPosition += (Vector3)Velocity * Time.Delta;
	}

	public override void Flinch( float time, Vector2 dir )
	{
		base.Flinch( time, dir );

		ModelRenderer.LocalRotation = new Angles(Game.Random.Float(-10f, 10f), 0f, Game.Random.Float(-10f, 10f));
	}

	public override void StopFlinching()
	{
		base.StopFlinching();

		ModelRenderer.LocalRotation = new Angles(0f, 0f, 0f);
	}

	protected override void HandleAnimation()
	{
		
	}

	public override void SetAnim( string name, bool forceRestart = false )
	{
		
	}

	protected override void StartDying( Vector2 dir, float force, Player player, DamageType damageType )
	{
		// prevent chest dying while not out of ground yet
		if ( IsSpawning )
			return;

		Log.Info( $"[ChestSpawn] {EnemyType} opened by {damageType} {TimeSinceSpawn.Relative:F1}s after spawn (player={(player.IsValid() ? player.GameObject.Name : "none")})" );

		IsDying = true;
		IsSpawning = false;

		ShowLight();

		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
			UnitStatuses.Values.ElementAt( i ).StartDying( player );

		_killingPlayer = player;
	}

	[Rpc.Broadcast]
	public void ShowLight()
	{
		_timeSinceReward = 0f;

		//ModelRenderer.SetBodyGroup( 1, 1 );

		CanAnimate = false;

		ModelRenderer?.Sequence.Name = "chest_open";
		SetPlaybackRate( 2f );

		PlayOpenEffects();

		//ResetMaterial();
		StopFlashing();
		StopFlinching();
	}

	protected virtual void PlayOpenEffects()
	{
		// todo: different sfx for evil chest

		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( 1.1f, 1.2f ), volume: 0.8f, maxDist: 450f );
	}

	[Rpc.Broadcast]
	public void DropRewardRpc()
	{
		_timeSinceReward = 0f;
		_numRewardsGiven++;

		if ( IsProxy )
			return;

		DropReward();
	}

	protected virtual 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( (30f, () => {
				Manager.Instance.SpawnCoin( pos, Game.Random.Int( 1, 5 ), dir );
				Manager.Instance.PlaySfxNearbyRpc( "chaching", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1f, 1.2f ), volume: 0.85f, maxDist: 300f );
			}) );
		}
		else
		{
			rewards.Add( (45f, () => {
				Manager.Instance.SpawnCoin( pos, Game.Random.Int( 1, 5 ), dir );
				Manager.Instance.PlaySfxNearbyRpc( "chaching", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1f, 1.2f ), volume: 0.85f, maxDist: 300f );
			}) );
		}

		rewards.Add( (5f, () => {
			Manager.Instance.SpawnItem( "health_pack", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1f, 2.2f ), volume: 1.05f, maxDist: 300f );
		}) );
		rewards.Add( (5f, () => {
			Manager.Instance.SpawnItem( "armor_item", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1.1f, 1.3f ), volume: 1.05f, maxDist: 300f );
		}) );

		var perkWeight = 18f;
		if ( _killingPlayer.IsValid() && _killingPlayer.Level >= 35 )
			perkWeight *= Utils.Map( _killingPlayer.Level, 35, 65, 1f, 0f, EasingType.SineIn );
		rewards.Add( (perkWeight, () => {
			if ( _killingPlayer.IsValid() )
				_killingPlayer.GiveRandomPerkItemRpc( pos, dir, Rarity.None, isReward: true );
			else
				Manager.Instance.SpawnItem( "reroll_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( (15f, () => {
			Manager.Instance.SpawnItem( "reroll_item", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1.2f, 1.3f ), volume: 0.85f, maxDist: 300f );
		}) );
		rewards.Add( (3f, () => {
			Manager.Instance.SpawnItem( "magnet", pos, dir );
			Manager.Instance.PlaySfxNearbyRpc( "scuffle", Position2D, pitch: Utils.Map( _numRewardsGiven, 1, 10, 1.3f, 1.4f ), volume: 0.95f, maxDist: 300f );
		}) );
		rewards.Add( (5f, () => {
			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( (4f, () => {
			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();
	}

	protected override void DropLoot( Player player )
	{
		// do nothing
	}

	protected override void SpawnGibs( Vector2 dir, float force, DamageType damageType )
	{
		//GameObject.Clone( $"prefabs/effects/{_debrisName}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( Game.Random.Float( 40f, 60f ) ), Rotation.Identity ) } );

		var gibFolderName = GibFolder;

		GameObject.Clone( $"prefabs/effects/dark_cloud_explosion.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( Game.Random.Float( 40f, 60f ) ), Rotation.Identity ) } );

		SpawnGibs( "fragment", Game.Random.Int( 3, 6 ), force, damageType );
		SpawnGibs( "fragment_2", Game.Random.Int( 3, 6 ), force, damageType );
		SpawnGibs( "fragment_3", Game.Random.Int( 3, 6 ), force, damageType );
	}

	void SpawnGibs( string name, int count, float force, DamageType damageType )
	{
		bool isEvilChest = this is ChestEvil;

		for ( int i = 0; i < count; i++ )
		{
			var color = Color.Lerp( TintFullHp, TintZeroHp, Game.Random.Float( 0.25f, 1f ) );
			if ( isEvilChest )
				color = Color.Lerp( color, new Color( 0.4f, 0f, 0f ), 0.8f );

			SpawnGoreGib(
				$"{GibFolder}/{name}",
				localPos: new Vector3( Game.Random.Float( -10f, 10f ), Game.Random.Float( -10f, 10f ), Game.Random.Float( 20f, 35f ) ) + Vector3.Random * Game.Random.Float( 0f, 30f ),
				localRot: Rotation.Random,
				scaleMultiplier: Game.Random.Float( 1f, 1.5f ),
				dir: Vector3.Random,
				force: Game.Random.Float( 0.25f, 3f ),
				color,
				damageType
			);
		}
	}

	protected override void PlayDeathSfx( Vector2 pos )
	{
		Manager.Instance.PlaySfxNearby( "chest.break", Position2D, pitch: Game.Random.Float( 1.15f, 1.2f ), volume: 1.4f, maxDist: 450f );
	}
}