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