Enemy component class for game enemies. It manages health, spawning, targeting, movement, attacks, damage processing, gibs/loot, explosions and animations, and contains many gameplay rules and RPCs for networked behavior.
using Sandbox;
using Sandbox.Diagnostics;
using Sandbox.UI;
using System;
using System.Numerics;
using static System.Net.Mime.MediaTypeNames;
[Flags]
public enum DamageResultFlags
{
None = 0,
Backstab = 1 << 0,
AlternateDmg = 1 << 1,
CoupDeGrace = 1 << 2,
FullHealthEnemy = 1 << 3,
DodgeHpDamage = 1 << 4,
PoisonIncreaseNearby = 1 << 5,
Mark = 1 << 6,
}
public class Enemy : Unit
{
[Property] public Color TintFullHp { get; set; }
[Property] public Color TintZeroHp { get; set; }
[Property, Hide] public float Health { get; set; }
public int CoinValueMin { get; set; }
public int CoinValueMax { get; set; }
public float CoinChance { get; set; }
public float MaxHealth { get; protected set; }
public virtual float GetMaxHealth() { return 0f; }
public override float HpPercent => Health / MaxHealth;
public virtual EnemyType EnemyType => EnemyType.None;
public virtual string GibFolder => Manager.GetStringForEnemyType( EnemyType );
public virtual float OverrideGibChance => -1f;
public virtual float HealthPackChanceMultiplier => 1f;
public virtual float GibScaleMultiplier => 1f;
public virtual float GibOffsetMultiplier => 1f;
public virtual float OverrideGibLifetime => 0f;
public virtual int ExtraDeathBloodSprayAmount => 0;
protected virtual bool HasLeftArm => true;
/// <summary>
/// Which enemy type to count this as for kill stats
/// </summary>
public virtual EnemyType KillStatEnemyType => EnemyType;
private string _currAnimName;
public bool CanAnimate { get; set; }
public virtual bool CanHaveTarget => !IsDying && !IsStunned;
public virtual bool CanAttack => !IsDying && !IsFearful && !IsStunned;
public virtual bool CanDamageByTouch => !IsDying && IsAttacking && !IsStunned && !IsInTheAir;
public virtual bool CanTurn => !IsStunned;
public virtual bool CanBeBackstabbed => true;
public virtual bool CountsAsKill => true;
public virtual bool CanMove => !IsDying && !IsStunned;
public virtual bool CanAccelerate => true;
public virtual bool ShouldCreateSpawnClouds => true;
public virtual bool IsMiniboss => false;
public virtual bool CanCombust => true;
// Miniboss armor scaling properties (based on difficulty)
protected float MinibossArmorStartTime
{
get
{
switch ( Manager.Instance.Difficulty )
{
case 0: default: return 13 * 60f; // 13 minutes for Normal
case 1: return 6 * 60f; // 6 minutes for Expert
case 2: return 4 * 60f; // 4 minutes for Cursed
}
}
}
protected float MinibossArmorPerMinute
{
get
{
switch ( Manager.Instance.Difficulty )
{
case 0: default: return 55f; // Normal
case 1: return 60f; // Expert
case 2: return 65f; // Cursed
}
}
}
protected virtual float MinibossBaseHealth
{
get
{
switch ( Manager.Instance.Difficulty )
{
case 0: default: return 420f;
case 1: return 490f;
case 2: return 540f;
}
}
}
protected virtual float MinibossHealthScale => 1f;
public override bool CanBeStunned => base.CanBeStunned && !IsSpawning;
public bool HasTarget { get; protected set; }
public Unit TargetUnit { get; set; }
[Sync] public float NetworkedPlaybackRate { get; set; } = 1f;
public Vector2 TargetPos { get; set; }
public float LoseTargetTime { get; protected set; }
public float DetectTargetRange { get; protected set; }
public float LoseTargetRange { get; protected set; }
private TimeSince _timeSinceCheckTarget;
private float _checkTargetDelay;
private const float CHECK_TARGET_TIME_MIN = 0.6f;
private const float CHECK_TARGET_TIME_MAX = 1.6f;
//protected TimeSince _timeSinceSawTarget;
private TimeSince _timeSinceWander;
private float _wanderTimeout;
public const float WANDER_TIMEOUT_MIN = 10f;
public const float WANDER_TIMEOUT_MAX = 20f;
private float _alwaysTargetPlayerTime;
public bool IsAttacking { get; protected set; }
private float _aggroTimer;
public float AggroRange { get; protected set; }
protected const float AGGRO_START_TIME = 0.2f;
protected const float AGGRO_LOSE_TIME = 0.4f;
protected float _fullMeleeAttackAnimSpeed;
public float Acceleration { get; set; }
public float AccelerationAttacking { get; set; }
public float DecelerationAttacking { get; set; }
[Sync] public float MoveTimeOffset { get; set; }
protected float _personalSpeedScale;
protected float _personalSpeedFreq;
protected float _personalTurnSpeed;
protected virtual bool ShouldRetreatFromTarget => IsFearful;
// Miniboss chest diagnostics: every miniboss death must end with DropLoot spawning a chest, so track
// the window between StartDying and the chest spawn to expose deaths that stall or bypass Die entirely
private bool _minibossDeathStarted;
private bool _minibossChestSpawned;
private bool _loggedStuckMinibossDeath;
private TimeSince _timeSinceMinibossDeathStarted;
protected TimeSince _timeSinceDamageTarget;
[Sync] public float DamageTargetDelay { get; set; }
[Sync] public float MeleeDamage { get; protected set; }
[Sync] public bool SyncedCanDamageByTouch { get; private set; }
public virtual float MeleeForce => 1f;
public virtual float MeleeRagdollForce => 1f;
public virtual float MeleeUpwardForceAmount => 0f;
public bool IsSpawning { get; set; }
public float SpawnProgress { get; protected set; }
public virtual float SpawnTime => 1.75f;
public virtual float SpawnZPos => -90f;
public virtual float GroundZPos => 0f;
public virtual bool AlmostFinishedSpawning => TimeSinceSpawn > SpawnTime * 0.5f * Manager.Instance.EnemySpawnTimeModifier;
protected virtual bool ShouldSpawnBloodDecal => true;
[Property, Hide] public bool ShouldSpawnInstantly { get; set; }
[Property, Hide] public Player PlayerCreator { get; set; }
private bool _isCelebrating;
public bool IsExploding { get; set; }
protected TimeSince _timeSinceExplodeStart;
protected bool _hasExploded;
protected float _explodeTime;
protected Player _playerWhoKilledUs;
protected bool _explodeFlashActive;
protected TimeSince _timeSinceExplodeFlash;
protected const float EXPLODE_BLINK_DELAY_START = 0.1f;
protected const float EXPLODE_BLINK_DELAY_END = 0.025f;
protected float _explosionRadius;
protected float _explosionDamage;
protected string _explodeAnim;
protected Vector3 _explodeStartScale;
protected Color _explosionColor;
protected bool _explosionDamagesEnemies;
protected float _explodeForce;
public Material ExplodeFlashMaterial { get; protected set; }
private Player _prevPlayerDamagedBy;
private List<Player> _playersDamagedBy = new();
protected RealTimeSince _realTimeSinceHurtSfx;
protected TimeSince _timeSinceInfightingDamageSfx;
protected TimeSince _timeSinceStartAttackingSfx;
public virtual DamageType MeleeAttackDamageType => DamageType.Melee;
private float _jumpFinishDamage;
private Player _jumpFinishDamagePlayerSource;
private float _jumpFinishStunTime;
protected TimeSince _timeSinceChangeState;
private float _flinchStartAnimProgress;
protected Vector3 ModelOffset { get; set; }
protected override void OnStart()
{
base.OnStart();
ShouldCheckBounds = true;
ResetMaterial();
float hp = MathF.Round( GetMaxHealth() * Manager.Instance.GetEnemyHealthModifier() );
Health = MaxHealth = hp;
// Containers and props (chests, barrels, trees) are excluded from the invisible enemy curse,
// since they never get hurt unless seen and would stay invisible forever
var canSpawnInvisible = !IsInanimate && this is not Tree;
var invisChance = canSpawnInvisible && Manager.Instance.LocalPlayer.IsValid() ? Manager.Instance.LocalPlayer.Stats[PlayerStat.InvisibleEnemyChance] : 0f;
if ( invisChance > 0f && Game.Random.Float( 0f, 1f ) < invisChance )
ModelRenderer.Tint = GetCurrentTint().WithAlpha( 0f );
else
ModelRenderer.Tint = GetCurrentTint();
ModelOffset = ModelRenderer.LocalPosition;
CanAnimate = true;
if( !ShouldSpawnInstantly )
{
IsSpawning = true;
SpawnProgress = 0f;
WorldPosition = WorldPosition.WithZ( SpawnZPos );
PlaySpawnAnim();
}
else
{
SpawnProgress = 1f;
PlayWalkAnim();
}
_explodeAnim = Game.Random.Float(0f, 1f) < 0.8f ? "Slide_Land" : "Slide_Enter";
ExplodeFlashMaterial = Manager.Instance.EnemyExplodeFlashMaterial;
_explosionColor = Color.Red;
_explosionDamagesEnemies = true;
_explodeForce = 800f;
_timeSinceChangeState = 0f;
_fullMeleeAttackAnimSpeed = 2f;
if ( IsProxy )
return;
CollideWithTags.Add( "enemy" );
CollideWithTags.Add( "player" );
CollideWithTags.Add( "obstacle" );
MoveTimeOffset = Game.Random.Float( 0f, 4f );
HasTarget = false;
TargetPos = new Vector2( Game.Random.Float( Manager.Instance.BOUNDS_MIN_SPAWN.x, Manager.Instance.BOUNDS_MAX_SPAWN.x ), Game.Random.Float( Manager.Instance.BOUNDS_MIN_SPAWN.y, Manager.Instance.BOUNDS_MAX_SPAWN.y ) );
_timeSinceWander = 0f;
_wanderTimeout = Game.Random.Float( WANDER_TIMEOUT_MIN, WANDER_TIMEOUT_MAX );
_alwaysTargetPlayerTime = Game.Random.Float( 0f, 1f ) > Utils.Map( Manager.Instance.ElapsedTime, 0f, 5f * 60f, 1f, 0f, EasingType.SineIn )
? 0f
: 60f;
// Miniboss armor scaling based on elapsed time
if ( IsMiniboss && Manager.Instance.ElapsedTime > MinibossArmorStartTime )
{
const float pivotTime = 20f * 60f; // 20 minutes — linear before, exponential after
const float exponentialRate = 0.07f; // higher = steeper curve after pivot
float minutesPastStart = (Manager.Instance.ElapsedTime - MinibossArmorStartTime) / 60f;
float rawArmor;
if ( Manager.Instance.ElapsedTime <= pivotTime )
{
rawArmor = minutesPastStart * MinibossArmorPerMinute;
}
else
{
float armorAtPivot = (pivotTime - MinibossArmorStartTime) / 60f * MinibossArmorPerMinute;
float minutesPastPivot = (Manager.Instance.ElapsedTime - pivotTime) / 60f;
rawArmor = armorAtPivot * MathF.Exp( minutesPastPivot * exponentialRate );
}
Armor = (int)MathF.Round( rawArmor / 10f ) * 10;
}
}
protected override void ApplySpawnScale()
{
if ( !UseSpawnScale )
return;
ModelRenderer.GameObject.LocalScale = SpawnScale;
if ( Collider is CapsuleCollider capsuleCollider )
{
capsuleCollider.Radius *= SpawnScale.x;
capsuleCollider.Start = capsuleCollider.Start.WithZ( capsuleCollider.Start.z * SpawnScale.z );
capsuleCollider.End = capsuleCollider.End.WithZ( capsuleCollider.End.z * SpawnScale.z );
}
}
protected virtual void DrawDebug()
{
Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"CanAnimate: {CanAnimate}\nPlayback: {ModelRenderer.PlaybackRate}\nAnimMod: {AnimSpeedModifier}\nAnim: {ModelRenderer.Sequence?.Name}", new Transform( WorldPosition ) );
Gizmo.Draw.Text( $"HitstopActive: {HitstopActive}", new global::Transform( WorldPosition ) );
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( !IsProxy && _minibossDeathStarted && !_minibossChestSpawned && !_loggedStuckMinibossDeath && _timeSinceMinibossDeathStarted > 10f )
{
_loggedStuckMinibossDeath = true;
Log.Warning( $"[MinibossDeath] {EnemyType} began dying {_timeSinceMinibossDeathStarted.Relative:F1}s ago but never dropped a chest! IsDying={IsDying} IsExploding={IsExploding} hasExploded={_hasExploded} timeSinceExplodeStart={_timeSinceExplodeStart.Relative:F1}s IsInTheAir={IsInTheAir} IsSpawning={IsSpawning} IsStunned={IsStunned} HitstopActive={HitstopActive} Health={Health} pos={Position2D}" );
}
if ( !IsProxy )
SyncedCanDamageByTouch = !IsSpawning && CanDamageByTouch;
if ( Manager.Instance.ShowDebug )
{
DrawDebug();
}
if ( IsFlashing )
HandleFlashing();
if ( IsFlinching )
HandleFlinching();
if ( CanAnimate && !IsFlinching && !HitstopActive && !IsStunned )
HandleAnimation();
if ( IsSpawning && !IsDying )
{
HandleSpawning();
return;
}
// todo: enemy subclasses shouldn't shoot/jump etc when IsExploding = true?
if ( IsExploding )
HandleExploding();
if ( IsProxy || Manager.Instance.IsGameOver )
return;
if ( CanHaveTarget )
HandleTarget();
if ( IsInTheAir )
return;
if ( CanTurn && !HitstopActive )
HandleRotation();
if ( CanMove && !HitstopActive )
HandleMovement();
}
protected virtual void HandleRotation()
{
var targetFacingDir = ((Vector3)TargetPos - WorldPosition).Normal.WithZ( 0f ); // todo: optimize?
if( ShouldRetreatFromTarget )
targetFacingDir *= -1f;
WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( targetFacingDir ), _personalTurnSpeed * Time.Delta * TimeScale );
}
protected virtual void HandleMovement()
{
var moveDir = (Vector2)WorldRotation.Forward;
if( CanAccelerate )
{
float acceleration = (IsAttacking ? AccelerationAttacking : Acceleration);
Velocity += moveDir * acceleration * Time.Delta * TimeScale * Manager.Instance.GlobalMovespeedModifier;
}
if ( Manager.Instance.IsWindActive )
Velocity += (Manager.Instance.GlobalWindForce / (Weight * 3f)) * Time.Delta;
Velocity *= Math.Max( 1f - Time.Delta * (IsAttacking ? DecelerationAttacking : Deceleration) * Manager.Instance.GlobalFrictionModifier, 0f );
WorldPosition += (Vector3)Velocity * GetMoveSpeedFactor() * Time.Delta;
}
protected virtual float GetMoveSpeedFactor()
{
return (0.5f + Utils.FastSin( MoveTimeOffset + Manager.Instance.ElapsedTime * _personalSpeedFreq * (IsAttacking ? 2f : 1f) ) * 0.5f) * _personalSpeedScale; // todo: should "(IsAttacking ? 2f : 1f)" be there?
}
protected virtual void HandleSpawning()
{
var adjustedSpawnTime = SpawnTime * Manager.Instance.EnemySpawnTimeModifier;
if ( TimeSinceSpawn > adjustedSpawnTime )
{
FinishSpawning();
}
else
{
//VfxOpacity = Utils.Map( TimeSinceSpawn, 0f, adjustedSpawnTime, 0f, 1f );
SpawnProgress = Utils.Map( TimeSinceSpawn, 0f, adjustedSpawnTime, 0f, 1f );
WorldPosition = WorldPosition.WithZ( Utils.Map( TimeSinceSpawn, 0f, adjustedSpawnTime, SpawnZPos, 0f, EasingType.SineOut ) );
}
}
protected virtual void FinishSpawning()
{
IsSpawning = false;
if ( Manager.Instance.IsGameOver )
{
if ( !_isCelebrating )
Celebrate( victory: Manager.Instance.IsBossDead );
return;
}
SpawnProgress = 1f;
PlayWalkAnim();
WorldPosition = WorldPosition.WithZ( GroundZPos );
}
protected virtual void HandleAnimation()
{
//if ( IsAttacking )
//{
// if ( ModelRenderer.SceneModel.CurrentSequence.TimeNormalized > 0.95f )
// PlayAttackAnim();
//}
if ( !IsSpawning )
{
if ( !IsProxy )
NetworkedPlaybackRate = GetAnimSpeedFactor() * AnimSpeedModifier * (IsStunned ? 0f : 1f);
SetPlaybackRate( NetworkedPlaybackRate );
}
//SetPlaybackRate( AnimSpeedModifier );
}
protected virtual float GetAnimSpeedFactor()
{
if ( HasTarget && TargetUnit.IsValid() && !TargetUnit.IsInTheAir )
{
float distSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
float attackDistSqr = MathF.Pow( AggroRange, 2f );
return Utils.Map( distSqr, attackDistSqr, 0f, 1f, _fullMeleeAttackAnimSpeed, EasingType.Linear ) * _personalSpeedScale;
//return (0.8f + Utils.FastSin( MoveTimeOffset + Manager.Instance.ElapsedTime * _personalSpeedFreq ) * 0.2f) * _personalSpeedScale * Utils.Map( distSqr, attackDistSqr, 0f, 1f, 4f, EasingType.Linear );
}
//return 1f;
return _personalSpeedScale;
//return (0.6f + Utils.FastSin( MoveTimeOffset + Manager.Instance.ElapsedTime * _personalSpeedFreq ) * 0.4f) * _personalSpeedScale;
}
public void SetPlaybackRate( float rate )
{
ModelRenderer.PlaybackRate = rate;
}
protected virtual void HandleTarget()
{
if ( HasTarget )
{
if ( !TargetUnit.IsValid() )
{
LoseTarget();
return;
}
}
if ( _timeSinceCheckTarget > _checkTargetDelay * (1f / TimeScale) )
CheckForTarget();
if ( HasTarget )
{
HandleAttacking( TargetUnit );
TargetPos = TargetUnit.Position2D + GetTargetOffset();
}
else
{
if ( TimeSinceSpawn > _alwaysTargetPlayerTime )
{
var closestPlayer = Manager.Instance.GetClosestPlayer( Position2D );
if ( closestPlayer.IsValid() )
GainTarget( closestPlayer, playSfx: false );
}
else
{
HandleWandering();
}
}
}
protected virtual Vector2 GetTargetOffset()
{
return Vector2.Zero;
}
protected void HandleWandering()
{
var wanderDistSqr = (TargetPos - Position2D).LengthSquared;
if ( wanderDistSqr < 50f * 50f || _timeSinceWander > _wanderTimeout * (1f / TimeScale) )
{
var closestPlayer = Manager.Instance.GetClosestPlayer( Position2D );
if ( closestPlayer != null )
{
Vector2 closestPlayerPos = closestPlayer.Position2D;
float rndOffset = 400f;
TargetPos = new Vector2(
MathX.Clamp( closestPlayerPos.x + Game.Random.Float( -rndOffset, rndOffset ), Manager.Instance.BOUNDS_MIN.x, Manager.Instance.BOUNDS_MAX.x ),
MathX.Clamp( closestPlayerPos.y + Game.Random.Float( -rndOffset, rndOffset ), Manager.Instance.BOUNDS_MIN.y, Manager.Instance.BOUNDS_MAX.y )
);
}
else
{
TargetPos = new Vector2( Game.Random.Float( Manager.Instance.BOUNDS_MIN.x, Manager.Instance.BOUNDS_MAX.x ), Game.Random.Float( Manager.Instance.BOUNDS_MIN.y, Manager.Instance.BOUNDS_MAX.y ) );
}
_timeSinceWander = 0f;
_wanderTimeout = Game.Random.Float( WANDER_TIMEOUT_MIN, WANDER_TIMEOUT_MAX );
}
}
public void CheckForTarget()
{
var closestPlayer = DetectClosestPlayer( out float closestDistSqr );
if ( closestPlayer.IsValid() )
{
GainTarget( closestPlayer );
}
else if ( HasTarget )
{
if ( TargetUnit.IsValid() && TargetUnit.IsDying )
{
LoseTarget();
}
}
_timeSinceCheckTarget = 0f;
_checkTargetDelay = Game.Random.Float( CHECK_TARGET_TIME_MIN, CHECK_TARGET_TIME_MAX );
}
public Unit DetectClosestPlayer( out float closestDistSqr )
{
Unit closestPlayer = null;
closestDistSqr = float.MaxValue;
var traceResults = Scene.Trace.Sphere( DetectTargetRange, WorldPosition, WorldPosition ).WithTag( "player" ).HitTriggersOnly().RunAll().ToList();
foreach ( var tr in traceResults )
{
var gameObject = tr.GameObject;
var player = gameObject.GetComponent<Player>();
if ( !player.IsValid() )
continue;
if ( player.IsDying || player.IsInTheAir )
continue;
var distSqr = (player.Position2D - Position2D).LengthSquared;
if ( distSqr < closestDistSqr )
{
closestPlayer = player;
closestDistSqr = distSqr;
}
}
return closestPlayer;
}
public virtual void GainTarget( Unit unit, bool playSfx = true )
{
if ( !CanHaveTarget )
return;
if ( !HasTarget && playSfx && Game.Random.Float(0f, 1f) < 0.25f )
Manager.Instance.PlaySfxNearbyRpc( "zombie.alert", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 0.65f, maxDist: 300f );
HasTarget = true;
TargetUnit = unit;
//_timeSinceSawTarget = 0f;
}
protected virtual void LoseTarget()
{
HasTarget = false;
TargetUnit = null;
_timeSinceWander = 0f;
_wanderTimeout = Game.Random.Float( WANDER_TIMEOUT_MIN, WANDER_TIMEOUT_MAX );
if ( IsAttacking )
StopAttacking();
}
protected virtual void HandleAttacking( Unit target )
{
if ( !target.IsValid() )
return;
var targetPlayer = target as Player;
if ( targetPlayer.IsValid() && targetPlayer.IsDead )
return;
float distSqr = (target.Position2D - Position2D).LengthSquared;
float attackDistSqr = MathF.Pow( AggroRange, 2f );
if ( !IsAttacking )
{
if ( CanAttack )
{
if ( distSqr < attackDistSqr && !target.IsInTheAir )
{
_aggroTimer += Time.Delta;
if ( _aggroTimer > AGGRO_START_TIME )
{
StartAttackingRpc();
_aggroTimer = 0f;
}
}
else
{
_aggroTimer = 0f;
}
}
}
else
{
if ( distSqr > attackDistSqr || target.IsInTheAir )
{
_aggroTimer += Time.Delta;
if ( _aggroTimer > AGGRO_LOSE_TIME )
{
StopAttacking();
}
}
else
{
//AnimSpeed = Utils.Map(dist_sqr, attack_dist_sqr, 0f, 1f, 4f, EasingType.Linear);
_aggroTimer = 0f;
}
}
}
[Rpc.Broadcast]
public void StartAttackingRpc()
{
StartAttacking();
}
public virtual void StartAttacking()
{
if( CanAnimate )
PlayAttackAnim();
IsAttacking = true;
if ( IsProxy )
return;
}
[Rpc.Broadcast]
public void StopAttacking()
{
IsAttacking = false;
if ( CanAnimate )
PlayWalkAnim();
if ( IsProxy )
return;
}
public override void Flash( float time, UnitFlashType flashType )
{
if ( IsFlashing )
return;
base.Flash( time, flashType );
if ( !ModelRenderer.IsValid() )
return;
Material mat = Manager.Instance.UnitFlashMaterials[flashType];
ModelRenderer.SetMaterial( mat );
ModelRenderer.Tint = Color.White;
}
protected override void StopFlashing()
{
base.StopFlashing();
ResetMaterial();
ModelRenderer.Tint = GetCurrentTint();
}
protected Color GetCurrentTint()
{
return Color.Lerp( TintFullHp, TintZeroHp, Utils.Map( Health, MaxHealth, 0f, 0f, 1f, EasingType.SineOut ) );
}
protected virtual void ResetMaterial()
{
ModelRenderer.ClearMaterialOverrides();
}
[Rpc.Host]
public void NotifyMeleeHitPlayerRpc( Player player )
{
OnMeleePlayer( player );
}
[Rpc.Broadcast]
public void DamageRpc( float damage, Player player, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool shouldFlinch = true, DamageResultFlags damageFlags = DamageResultFlags.None )
{
Damage( damage, player, damageType, hitPos, force, isCrit, shouldFlinch, damageFlags );
}
protected virtual void Damage( float damage, Player player, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool shouldFlinch = true, DamageResultFlags damageFlags = DamageResultFlags.None )
{
var dir = force.Normal;
var totalDamage = damage;
if ( (IsShielded && damageType != DamageType.Self) || IsInvincible )
{
damage = 0f;
totalDamage = 0f;
force = Vector2.Zero;
isCrit = false;
}
else
{
if ( player.IsValid() )
{
GetAdditionalDamageFromPlayer( ref damage, ref damageFlags, player, damageType, dir );
totalDamage = damage;
if ( !player.IsProxy )
player.OnDamageEnemy( this, damage, damageType, dir, isCrit );
_prevPlayerDamagedBy = player;
if ( !_playersDamagedBy.Contains( player ) )
_playersDamagedBy.Add( player );
}
if ( !IsDying )
{
if ( Armor > 0 )
{
int armorDamage;
// If damage has a decimal and armor can absorb all damage, use probabilistic rounding
float decimalPart = damage - MathF.Floor( damage );
if ( decimalPart > 0f && Armor > damage )
{
// Probabilistic rounding: preserves expected damage value
if ( Game.Random.Float( 0f, 1f ) > decimalPart )
armorDamage = (int)MathF.Floor( damage );
else
armorDamage = (int)MathF.Ceiling( damage );
}
else
{
// Armor can't absorb all damage, or damage is whole number - floor it
armorDamage = (int)MathF.Min( Armor, MathF.Floor( damage ) );
}
if ( armorDamage > 0 )
{
damage -= armorDamage;
LoseArmor( armorDamage, -dir );
}
}
Health = Math.Max( Health - damage, 0f );
OnAdjustHealth( -Math.Min( Health, damage ) );
//Health = Health - damage;
////if ( Health > 0f )
// Flash( 0.12f, UnitFlashType.EnemyDmg );
//Health = Math.Max( Health, 0f );
}
}
DamageVfx( totalDamage, damageType, hitPos, dir, shouldFlinch, isCrit, player, damageFlags );
if ( IsProxy )
return;
if ( isCrit && player.IsValid() )
player.AddResultsStatRpc( ResultStat.Crit, 1 );
//if( damage > 0f )
{
for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
UnitStatuses.Values.ElementAt( i ).OnHurt( damage, player, enemySource: null, damageType, isSelfInflicted: false );
}
//if( totalDamage > 0f )
{
for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
UnitStatuses.Values.ElementAt( i ).OnHit( totalDamage, player, enemySource: null, damageType, isSelfInflicted: false );
}
//if( damageType == DamageType.Bullet )
{
ExplosionVelocity += force;
}
if ( IsDying )
return;
if ( Health <= 0f )
{
var hitStrength = Utils.Map( damage, 1f, 5f, 0.25f, 1f, EasingType.Linear ) * Utils.Map( damage, 5f, 100f, 1f, 3f, EasingType.SineIn );
if ( damageType == DamageType.Explosion )
hitStrength *= 2.5f;
StartDying( dir, hitStrength, player, damageType );
}
else
{
if ( player.IsValid() )
{
if ( CanHaveTarget )
{
if ( !HasTarget || (player.Position2D - Position2D).LengthSquared < (TargetUnit.Position2D - Position2D).LengthSquared * 0.8f )
GainTarget( player );
}
//if ( player.IsValid() )
// Stun( player, enemySource: null, 0.4f );
//BecomeInvincible( 1f );
//player.Chain( anchorUnit: this, chainPos: Position2D, chainLength: 150f, lifetime: 4f );
//if( Manager.Instance.Players.Count == 2 )
//{
// if ( player == Manager.Instance.Players[0] )
// player.Chain( anchorUnit: Manager.Instance.Players[1], chainPos: Manager.Instance.Players[1].Position2D, chainLength: 150f, lifetime: 4f );
// else
// player.Chain( anchorUnit: Manager.Instance.Players[0], chainPos: Manager.Instance.Players[0].Position2D, chainLength: 150f, lifetime: 4f );
//}
if ( player.IsValid() && damageType == DamageType.Explosion && Game.Random.Float( 0f, 1f ) < player.GetSyncStat(PlayerStat.ExplosionFearChance) )
{
//if ( !IsFearful )
// PlaySfx( "fear", unit.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 0.7f );
Fear( player, enemySource: null, lifetime: player.GetSyncStat(PlayerStat.FearLifetime) );
}
}
}
}
void DamageVfx( float damage, DamageType damageType, Vector3 hitPos, Vector2 dir, bool shouldFlinch, bool isCrit, Player player, DamageResultFlags damageFlags )
{
float size;
if ( damage < 5f ) size = Utils.Map( damage, 1f, 5f, 1.4f, 1.65f, EasingType.QuadOut );
else if ( damage < 20f ) size = Utils.Map( damage, 5f, 20f, 1.8f, 2.2f, EasingType.Linear );
else size = Utils.Map( damage, 20f, 100f, 2.2f, 3f, EasingType.Linear );
if ( isCrit ) size *= 1.2f;
//if ( flags.HasFlag(DamageResultFlags.Backstab) ) size *= 1.1f;
//if ( flags.HasFlag(DamageResultFlags.CoupDeGrace) ) size *= 1.1f;
// FLOATER
Color floaterColor;
if ( damageType == DamageType.Poison ) floaterColor = damageFlags.HasFlag( DamageResultFlags.PoisonIncreaseNearby ) ? new Color( 0f, 0.3f, 0f ) : new Color( 0.5f, 0.7f, 0.5f );
else if ( damageType == DamageType.PoisonFinish ) floaterColor = new Color( 0.45f, 0.65f, 0.45f );
else if ( damageType == DamageType.FrostArmor ) floaterColor = new Color( 0.75f, 0.75f, 1f );
else if ( damageType == DamageType.OrbitingBlade ) floaterColor = new Color( 0.05f, 0.05f, 0.05f );
else if ( damageFlags.HasFlag( DamageResultFlags.DodgeHpDamage ) ) floaterColor = new Color( 1f, 0.5f, 0.5f );
else if ( damageType == DamageType.Radiation ) floaterColor = new Color( 0.65f, 1f, 0.3f );
else if ( damageFlags.HasFlag( DamageResultFlags.Mark ) ) floaterColor = new Color( 0.75f, 0.4f, 1f );
else floaterColor = isCrit ? Color.Yellow : Color.White;
FloaterType floaterType = FloaterType.Damage;
if ( damageType == DamageType.Poison ) floaterType = FloaterType.Poison;
else if ( damageType == DamageType.PoisonFinish ) floaterType = FloaterType.PoisonFinish;
else if ( damageType == DamageType.Shock ) floaterType = FloaterType.Shock;
else if ( damageType == DamageType.Fire ) floaterType = FloaterType.Fire;
else if ( damageType == DamageType.FrostArmor ) floaterType = FloaterType.FrostDmg;
else if ( damageType == DamageType.OrbitingBlade ) floaterType = FloaterType.OrbiterBlade;
else if ( damageType == DamageType.Thorns ) floaterType = FloaterType.Thorns;
else if ( damageType == DamageType.Radiation ) floaterType = FloaterType.Radiation;
else if ( damageFlags.HasFlag( DamageResultFlags.Backstab ) ) floaterType = FloaterType.Backstab;
else if ( damageFlags.HasFlag( DamageResultFlags.DodgeHpDamage ) ) floaterType = FloaterType.DodgeHpDamage;
else if ( damageFlags.HasFlag( DamageResultFlags.Mark ) ) floaterType = FloaterType.Mark;
var showVagueDmgFloater = player.IsValid() && player.GetSyncStat( PlayerStat.DontCauseDmgNumbers ) > 0f;
if ( showVagueDmgFloater )
{
string str = CurseDontCauseDmgNumbers.GetDmgNumberText( this, damage ) + Manager.GetDamageResultExtraText( damageFlags );
Manager.Instance?.SpawnFloaterText( hitPos, str, floaterColor, size, floaterType );
}
else
{
Manager.Instance?.SpawnDamageNumber( hitPos, damage, floaterColor, size, floaterType, damageFlags );
}
if ( damageType == DamageType.Bullet || damageType == DamageType.Boomerang || damageType == DamageType.Spear || damageType == DamageType.Punch )
{
var scaleMultiplier = Utils.Map( damage, 1f, 5f, 0.4f, 1f, EasingType.Linear ) * Utils.Map( damage, 5f, 30f, 1f, 1.5f, EasingType.Linear ) * (isCrit ? 1.2f : 1f);
Color impactEffectColor = isCrit ? Color.Yellow : Color.White;
Manager.Instance.SpawnBulletImpactParticles( hitPos, -dir, impactEffectColor, scaleMultiplier );
}
PlayHurtSfx( damage, damageType, hitPos, player, damageFlags );
if ( !(damage > 0f) )
{
var invincibleShakeStrength = 4f;
invincibleShakeStrength *= (1f / WorldScale.x);
Shake( startStrength: invincibleShakeStrength, endStrength: invincibleShakeStrength, time: 0.025f );
return;
}
if( !IsDying )
{
Flash( 0.12f, UnitFlashType.EnemyDmg );
if ( shouldFlinch )
{
if ( shouldFlinch && CanAnimate )
Flinch( time: Game.Random.Float( 0.08f, 0.12f ), dir );
}
if( CanHitstop )
{
var hitstopTime = damage < 5f
? Utils.Map( damage, 0f, 5f, 0f, 0.05f, EasingType.Linear )
: Utils.Map( damage, 5f, 100f, 0.05f, 0.2f, EasingType.QuadIn );
//Hitstop( hitstopTime * Game.Random.Float( 0.95f, 1.05f ) * HitstopTimeModifier );
Hitstop( hitstopTime );
}
}
// shake
var shakeStrength = damage < 5f
? Utils.Map( damage, 0f, 5f, 0.5f, 3f, EasingType.Linear )
: Utils.Map( damage, 5f, 100f, 3f, 5f, EasingType.QuadIn );
shakeStrength *= (1f / WorldScale.x); // shake less the bigger the enemy is
var shakeTime = damage < 5f
? Utils.Map( damage, 0f, 5f, 0f, 0.025f, EasingType.Linear )
: Utils.Map( damage, 5f, 100f, 0.025f, 0.05f, EasingType.QuadIn );
Shake( startStrength: shakeStrength, endStrength: shakeStrength, time: shakeTime );
}
public virtual void PlayHurtSfx( float damage, DamageType damageType, Vector3 hitPos, Player player, DamageResultFlags damageFlags )
{
// sfx
if ( _realTimeSinceHurtSfx > 0.0175f )
{
var isFromPlayer = player.IsValid();
if ( damage == 0f )
{
Manager.Instance.PlaySfxNearby( "bullet.impact", hitPos, pitch: Game.Random.Float( 1.4f, 1.5f ), volume: 1f, maxDist: 300f );
}
else if( damageFlags.HasFlag( DamageResultFlags.DodgeHpDamage ) )
{
Manager.Instance.PlaySfxNearby( "dodge_hp_damage", hitPos, pitch: Game.Random.Float( 0.85f, 0.9f ), volume: 1.4f, maxDist: 400f );
}
else if ( damageType == DamageType.Bullet || damageType == DamageType.Boomerang || damageType == DamageType.Spear || damageType == DamageType.Punch )
{
if ( damageType == DamageType.Punch )
Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 1.5f, 1.7f, EasingType.SineIn ), volume: 0.95f, maxDist: 400f );
else
Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.9f, 1.25f, EasingType.SineIn ), volume: 0.95f, maxDist: 400f );
}
//else if ( Player.IsDamageTypeMelee( damageType ) && !isFromPlayer )
//{
// if ( _timeSinceInfightingDamageSfx > 0.25f )
// {
// if ( damageType == DamageType.MeleeRunnerBite )
// Manager.Instance.PlaySfxNearby( "runner.bite", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 0.6f, maxDist: 400f );
// else
// Manager.Instance.PlaySfxNearby( "zombie.attack.player", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 0.6f, maxDist: 400f );
// _timeSinceInfightingDamageSfx = 0f;
// }
//}
else if ( damageType == DamageType.DashSlash ) { Manager.Instance.PlaySfxNearby( "player.dash.slash.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.9f, 1.1f, EasingType.SineIn ) * Game.Random.Float( 0.95f, 1.05f ), volume: 0.8f, maxDist: 350f ); }
else if ( damageType == DamageType.Fire ) { Manager.Instance.PlaySfxNearby( "burn_2", hitPos, pitch: Game.Random.Float( 1.15f, 1.35f ), volume: 0.7f, maxDist: 300f ); }
else if ( damageType == DamageType.Poison ) { Manager.Instance.PlaySfxNearby( "poisoned", hitPos, pitch: Game.Random.Float( 1.55f, 1.65f ) * (damageFlags.HasFlag(DamageResultFlags.PoisonIncreaseNearby) ? 2f : 1f), volume: 0.35f, maxDist: 300f ); }
else if ( damageType == DamageType.SpikerHead ) { Manager.Instance.PlaySfxNearby( "spike.stab", hitPos, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.8f, maxDist: 300f ); }
else if ( damageType == DamageType.SpitterProjectile || damageType == DamageType.SpitterProjectileHoming ) { Manager.Instance.PlaySfxNearby( "splash", hitPos, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f ); }
else if ( damageType == DamageType.Aoe || damageType == DamageType.BulletSplash ) { /* no sfx */ }
else if ( damageType == DamageType.Radiation ) { /* no sfx */ }
else if ( damageType == DamageType.Shock ) { /* no sfx */ }
else if ( damageType == DamageType.Explosion ) { /* no sfx */ }
else if ( damageType == DamageType.JumpFinish ) { Manager.Instance.PlaySfxNearby( "slam", hitPos, pitch: Game.Random.Float( 0.85f, 0.95f ), volume: 0.8f, maxDist: 150f ); }
else if ( damageType == DamageType.OrbitingBlade ) { Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.5f, 0.7f, EasingType.SineIn ), volume: 0.6f, maxDist: 300f ); }
else { Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.9f, 1.25f, EasingType.SineIn ) * Game.Random.Float( 0.95f, 1.05f ), volume: 0.85f, maxDist: 300f ); }
// todo: other hit sfx
_realTimeSinceHurtSfx = 0f;
}
}
// todo: canBackstab param is redundant, can be determined from dir param
void GetAdditionalDamageFromPlayer( ref float damage, ref DamageResultFlags flags, Player player, DamageType damageType, Vector2 dir )
{
if ( !player.IsValid() )
return;
float dmgMult = 1f;
float dmgAdd = 0f;
GetAdditionalDamageFromPlayer( ref dmgMult, ref dmgAdd, ref flags, player, damageType, dir );
damage += dmgAdd;
damage *= dmgMult;
}
void GetAdditionalDamageFromPlayer( ref float mult, ref float add, ref DamageResultFlags flags, Player player, DamageType damageType, Vector2 dir )
{
mult *= player.GetSyncStat( PlayerStat.OverallDamageMultiplier );
if( damageType != DamageType.Bullet )
mult *= player.GetSyncStat( PlayerStat.NonBulletDamageMultiplier );
var maxHp = player.GetSyncStat( PlayerStat.MaxHp );
var lowHealthMult = player.GetSyncStat( PlayerStat.LowHealthDamageMultiplier );
if ( lowHealthMult > 1f )
mult *= Utils.Map( player.Health, maxHp, 0f, 1f, lowHealthMult );
var fullHealthMult = player.GetSyncStat( PlayerStat.FullHealthDamageMultiplier );
if ( fullHealthMult > 1f && !(player.Health < maxHp) )
mult *= fullHealthMult;
var numBanishMult = player.GetSyncStat( PlayerStat.DamagePercentPerBanished );
if ( numBanishMult > 0f )
mult *= (1f + player.NumBanishedPerks * numBanishMult);
var zeroRerollMult = player.GetSyncStat( PlayerStat.ZeroRerollDmgMult );
if ( zeroRerollMult > 0f && player.NumRerollAvailable == 0 )
mult *= (1f + zeroRerollMult);
if ( IsFrozen )
{
if ( damageType == DamageType.Fire )
{
mult *= player.GetSyncStat(PlayerStat.FreezeFireDamageMultiplier);
}
}
if ( IsFearful )
mult *= player.GetSyncStat(PlayerStat.FearDamageMultiplier); // todo: these multipliers will stack too much?
bool didBackstab = CanBeBackstabbed
&& dir.LengthSquared > 0f
&& (damageType == DamageType.Bullet || damageType == DamageType.Punch)
&& player.GetSyncStat( PlayerStat.BackstabBonusDamagePercent ) > 0f
&& Vector2.Dot( dir, (Vector2)WorldRotation.Forward ) > 0.1f;
if ( didBackstab )
{
flags |= DamageResultFlags.Backstab;
mult *= (1f + player.GetSyncStat( PlayerStat.BackstabBonusDamagePercent ));
}
if ( !(Health < MaxHealth) && player.GetSyncStat(PlayerStat.HealthyUnitDamagePercent) > 1f )
{
flags |= DamageResultFlags.FullHealthEnemy;
mult *= player.GetSyncStat(PlayerStat.HealthyUnitDamagePercent);
}
bool didAlternateDmg = player.GetSyncStat( PlayerStat.AlternatePlayerDamagePercent ) > 0f
&& _prevPlayerDamagedBy.IsValid()
&& _prevPlayerDamagedBy != player;
if ( didAlternateDmg )
{
flags |= DamageResultFlags.AlternateDmg;
mult *= (1f + player.GetSyncStat( PlayerStat.AlternatePlayerDamagePercent ));
// todo: sfx
}
bool didCoupDeGrace = player.GetSyncStat(PlayerStat.CoupDeGracePercent) > 0f && !_playersDamagedBy.Contains( player );
if ( didCoupDeGrace )
{
flags |= DamageResultFlags.CoupDeGrace;
float dmgAdd = (MaxHealth - Health) * player.GetSyncStat( PlayerStat.CoupDeGracePercent );
if ( dmgAdd > 0f )
{
add += dmgAdd;// * GetDamageMultiplier();
// todo: sfx
}
}
//if ( IsFrozen && TimeSinceFrozen > 0.1f && player.GetSyncStat( PlayerStat.FreezeDoubleDmg ) > 0f )
//{
// mult *= 2f; // todo: multiplier should be added with other multipliers, instead of deciding if damage is doubled before or after other dmg modifiers?
// Manager.Instance.PlaySfxNearbyRpc( "frozen", Position2D, pitch: Game.Random.Float( 1.4f, 1.45f ), volume: 1.2f, maxDist: 400f );
// // does this work? needs to be done with a broadcast instead? maybe remove freeze dbl damage perk
// if (!IsProxy)
// RemoveUnitStatus<UnitStatusFreeze>();
//}
}
public override void Flinch( float time, Vector2 dir )
{
base.Flinch( time, dir );
_flinchStartAnimProgress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;
if( CanAnimate )
SetPlaybackRate( Game.Random.Float( 0.5f, 2.5f ) );
ModelRenderer.Sequence.Blending = false;
PlayFlinchAnim();
}
public override void StopFlinching()
{
base.StopFlinching();
ModelRenderer.Sequence.Blending = true;
if ( !CanAnimate )
return;
if ( IsAttacking )
PlayAttackAnim();
else
PlayWalkAnim();
if ( ModelRenderer.SceneModel.CurrentSequence != null )
ModelRenderer.SceneModel.CurrentSequence.TimeNormalized = _flinchStartAnimProgress;
}
[Rpc.Broadcast]
public void ExecuteRpc( Vector2 dir, float hitStrength, Player player, DamageType damageType )
{
Manager.Instance.PlaySfxNearby( "execute2", Position2D, pitch: Game.Random.Float(1.1f, 1.2f ), volume: 1f, maxDist: 350f );
GameObject.Clone( "prefabs/effects/execute.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition + Vector3.Up * Height * 1.5f ) } );
if ( IsProxy )
return;
StartDying( dir, hitStrength, player, damageType );
}
protected virtual void StartDying( Vector2 dir, float force, Player player, DamageType damageType )
{
Assert.True( !IsProxy );
IsDying = true;
IsSpawning = false;
var willCombust = player.IsValid() && player.CombustionActive && CanCombust;
if ( IsMiniboss )
StartMinibossDeathWatchdog( willCombust ? "combust" : "direct", player, damageType );
for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
UnitStatuses.Values.ElementAt( i ).StartDying( player );
if ( willCombust )
Combust( player );
else
DieRpc( dir, force, player, damageType );
}
// Host-side diagnostics for the "miniboss died but no chest" reports: every miniboss death must end in
// DropLoot spawning a chest, so log when a death starts and warn if it stalls before getting there
protected void StartMinibossDeathWatchdog( string path, Player player, DamageType damageType )
{
_minibossDeathStarted = true;
_timeSinceMinibossDeathStarted = 0f;
Log.Info( $"[MinibossDeath] {EnemyType} StartDying ({path}, {damageType}, player={(player.IsValid() ? player.GameObject.Name : "none")}) at {Position2D}, t={Manager.Instance.ElapsedTime:F1}s" );
}
public void Combust( Player player )
{
StartExplodingRpc( time: 1.5f, player.CombustionRadius, MaxHealth * player.CombustionDamageFactor, player );
player.DisableCombustion();
}
[Rpc.Broadcast]
public void DieRpc( Vector2 dir, float force, Player player, DamageType damageType )
{
Die( dir, force, player, damageType );
}
public virtual void Die( Vector2 dir, float force, Player player, DamageType damageType )
{
SpawnGibs( dir, force, damageType );
PlayDeathSfx( Position2D );
if ( IsProxy )
return;
for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
UnitStatuses.Values.ElementAt( i ).Die( player );
if ( player.IsValid() )
player.KillEnemy( this, damageType );
if ( damageType == DamageType.Explosion )
Manager.Instance.NumExplosionKilledEnemies++;
DropLoot( player );
Remove();
}
public void Remove()
{
Manager.Instance.RemoveEnemy( EnemyType );
GameObject.Destroy();
}
protected virtual void PlayDeathSfx( Vector2 pos )
{
Manager.Instance.PlayEnemyDeathSfx( Position2D );
if ( IsMiniboss )
Manager.Instance.PlaySfxNearby( "miniboss_die", pos, pitch: Game.Random.Float( 1.7f, 1.9f ), volume: 2.5f, maxDist: 1000f );
}
protected virtual void DropLoot( Player player )
{
if ( IsMiniboss )
{
Vector2 chestPos = Manager.Instance.ClampPosToBounds( Position2D + Utils.GetRandomVector() * Game.Random.Float( 1f, 7f ) );
Log.Info( $"[MinibossDeath] {EnemyType} died at {Position2D}, t={Manager.Instance.ElapsedTime:F1}s — spawning chest at {chestPos}" );
Manager.Instance.SpawnEnemy( EnemyType.Chest, chestPos, rotAngle: -90f + Game.Random.Float( -30f, 30f ) );
Log.Info( $"[MinibossDeath] SpawnEnemy(Chest) call completed for {EnemyType}" );
_minibossChestSpawned = true;
}
var coinChance = !player.IsValid() || Manager.Instance.ElapsedTime > 60f
? CoinChance
: Utils.Map( player.Level, 0, 3, 1f, CoinChance );
var dropDir = player.IsValid() ? (player.Position2D - Position2D).Normal : Utils.GetRandomVector();
if ( Game.Random.Float( 0f, 1f ) <= coinChance )
{
var magnetizeChance = player.IsValid() ? player.GetSyncStat( PlayerStat.KillMagnetizeCoinChance ) : 0f;
Player magnetizePlayer = Game.Random.Float(0f, 1f) < magnetizeChance ? player : null;
Manager.Instance.SpawnCoin( Position2D, value: Game.Random.Int( CoinValueMin, CoinValueMax ), dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 200f ), magnetizePlayer );
return;
}
var lowestHpPercent = 1f;
foreach ( Player p in Manager.Instance.AlivePlayers )
lowestHpPercent = MathF.Min( lowestHpPercent, p.HpPercent );
var healthPackCount = Scene.GetAllComponents<HealthPack>().Count();
var healthPackChance = Utils.Map( lowestHpPercent, 1f, 0f, 0f, 0.08f ) * Utils.Map( healthPackCount, 0, 4, 1f, 0f, EasingType.SineOut );
healthPackChance *= Utils.Select( Manager.Instance.Difficulty, 1.2f, 0.8f, 0.65f );
healthPackChance *= HealthPackChanceMultiplier;
if ( Manager.Instance.HasSpawnedBoss )
healthPackChance *= Utils.Map( Manager.Instance.ElapsedTime, Manager.BOSS_SPAWN_MINUTES_DEFAULT, 40f, 1f, 0.1f );
if ( Game.Random.Float( 0f, 1f ) < healthPackChance )
{
Manager.Instance.SpawnItemRpc( "health_pack", Position2D, dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 200f ) );
return;
}
var minutes = Manager.Instance.ElapsedTime / 60f;
if ( Game.Random.Float( 0f, 1f ) < Utils.Map( minutes, 0f, 5f, 0.03f, 0.005f ) )
{
Manager.Instance.SpawnItemRpc( "reroll_item", Position2D, dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 200f ) );
return;
}
}
public virtual void SetAnim( string name, bool forceRestart = false )
{
if( ModelRenderer is null )
return;
if ( !forceRestart && _currAnimName == name )
return;
ModelRenderer.Sequence.Name = name;
_currAnimName = name;
}
protected virtual void PlaySpawnAnim()
{
SetAnim( "Spawn" );
}
protected virtual void PlayAttackAnim()
{
SetAnim( "Attack" );
}
protected virtual void PlayWalkAnim()
{
SetAnim( "Walk" );
}
protected virtual void PlayFlinchAnim()
{
if ( !CanAnimate )
return;
SetAnim( "Hurt", forceRestart: true );
}
protected virtual void PlayJumpAnim()
{
SetAnim( "Jump" );
}
protected override void OnDestroy()
{
base.OnDestroy();
if ( !IsProxy && IsMiniboss && !_minibossChestSpawned && Manager.Instance.IsValid() && !Manager.Instance.IsGameOver )
Log.Warning( $"[MinibossDeath] {EnemyType} destroyed without spawning a chest! deathStarted={_minibossDeathStarted} IsDying={IsDying} Health={Health} pos={Position2D} t={Manager.Instance.ElapsedTime:F1}s" );
}
public override void Colliding( Thing other, float percent, float dt )
{
base.Colliding( other, percent, dt );
//if ( IsInTheAir )
// return;
if ( other is Enemy enemy )
{
if ( IsSpawning && !enemy.IsSpawning )
return;
if ( !Position2D.Equals( enemy.Position2D ) )
{
AddRepelVelocity( (Position2D - enemy.Position2D).Normal * enemy.PushStrength * percent * enemy.SpawnProgress * ( enemy.Weight / Weight ) * dt ); // todo: use ( 1f / Weight ) instead, so enemies can have huge weight and not move (like Trees) without pushing other enemies too much?
}
// punch impact
//if (ExplosionVelocity.LengthSquared > 250f * 250f && _timeSinceDamageTarget > 0.25f)
//{
// enemy.DamageRpc( 3f, this, DamageType.Aoe, enemy.Position2D, (enemy.Position2D - Position2D).Normal * ExplosionVelocity.Length * 0.6f, isCrit: false, shouldFlinch: true );
// //enemy.Stun( null, null, 0.15f );
// _timeSinceDamageTarget = 0f;
//}
}
else if ( other is Player player )
{
if ( IsSpawning )
return;
if ( !Position2D.Equals( player.Position2D ) )
{
if ( !player.IsDead )
{
if ( !(player.IgnorePhysicsAmount > 0) )
AddRepelVelocity( (Position2D - player.Position2D).Normal * player.PushStrength * percent * (player.Weight / Weight) * dt );
}
else
{
AddRepelVelocity( (Position2D - player.Position2D).Normal * player.PushStrength * percent * (player.Weight / Weight) * 0.2f * dt );
}
}
}
else
{
// obstacle
AddRepelVelocity( (Position2D - other.Position2D).Normal * other.PushStrength * percent * (other.Weight / Weight) * dt );
}
}
protected virtual void OnMeleePlayer( Player player )
{
}
protected virtual void SpawnGibs( Vector2 dir, float force, DamageType damageType )
{
if ( IsBoss )
{
var bloodExplosionGo = GameObject.Clone( "prefabs/effects/blood_explosion_miniboss.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( Game.Random.Float( 20f, 40f ) ), Rotation.Identity ) } );
}
else
{
var bloodExplosionGo = GameObject.Clone( "prefabs/effects/blood_explosion.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( Game.Random.Float( 40f, 60f ) ), Rotation.Identity ) } );
var bloodSprayer = bloodExplosionGo.GetComponentInChildren<BloodSprayer>();
bloodSprayer.StopSprayingBlood();
var sprayBloodChance = Utils.Map( Manager.Instance.NumEnemies, 0, 250, 1f, 0.3f ) * Utils.Map( Manager.Instance.NumDecals, 0, 200, 1f, 0f ) * (damageType == DamageType.Explosion ? Utils.Map( Manager.Instance.NumExplosionKilledEnemies, 0, 9, 1f, 0.15f ) : 1f);
if ( ExtraDeathBloodSprayAmount > 0 )
sprayBloodChance = 1f;
if ( Game.Random.Float( 0f, 1f ) < sprayBloodChance )
{
var emitter = bloodSprayer.GetComponent<ParticleEmitter>();
var particleEffect = bloodSprayer.GetComponent<ParticleEffect>();
int numSpray = Game.Random.Int( (int)Utils.Map( Manager.Instance.NumDecals, 0, 120, 3, 1 ), (int)Utils.Map( Manager.Instance.NumDecals, 0, 150, 12, 7 ) ) + ExtraDeathBloodSprayAmount;
//Log.Info( $"numSpray: {numSpray} ExtraDeathBloodSprayAmount: {ExtraDeathBloodSprayAmount}" );
for ( int i = 0; i < numSpray; i++ )
emitter.Emit( particleEffect );
}
else
{
bloodSprayer.GameObject.Enabled = false;
}
}
if ( ShouldSpawnBloodDecal )
{
var pos = WorldPosition.WithZ( 1f );
var rot = new Angles( 90f, Game.Random.Float(0f, 360f), 0f );
var scale = Game.Random.Float( 0.95f, 1.45f );
//scale = 1f;
var bloodDecalSplatGo = GameObject.Clone( "prefabs/effects/decal_blood_splat.prefab", new global::Transform( pos, rot, new Vector3( 2f, scale, scale ) ) );
var bloodDecalSplat = bloodDecalSplatGo.GetComponent<BloodSplatDecal>();
bloodDecalSplat.Lifetime = Game.Random.Float( 2.5f, 4f ) * Utils.Map(Manager.Instance.NumEnemies, 5, 100, 2f, 0.5f );
bloodDecalSplat.ColorFadeEasingType = EasingType.SineIn;
//var bloodDecalGo = GameObject.Clone( "prefabs/effects/decal_blood.prefab", new global::Transform( WorldPosition.WithZ( 1f ), new Angles( 90f, Game.Random.Float( 0f, 360f ), 0f ) ) );
//bloodDecalGo.GetComponent<BloodDecal>().Lifetime = Game.Random.Float( 5f, 8f ) * Utils.Map( Manager.Instance.NumDecals, 0, 30, 1f, 0.25f );
}
// todo: if the damageType is fire, gibs should fall instead of flying away - need to prevent initial overlap from giving force
if ( HasLeftArm )
{
SpawnGoreGib(
$"{GibFolder}/left_hand",
localPos: new Vector3( 9.5f, 16f, 30f ) * GibOffsetMultiplier,
localRot: new Angles( 0f, -60f, 0f ),
//localScale: new Vector3( 0.8f, 0.347f, 0.147f ),
scaleMultiplier: GibScaleMultiplier,
dir,
force,
TintZeroHp,
damageType
);
}
SpawnGoreGib(
$"{GibFolder}/left_hand",
localPos: new Vector3( 9.5f, -16f, 30f ) * GibOffsetMultiplier,
localRot: new Angles( 0f, 60f, 0f ),
scaleMultiplier: GibScaleMultiplier,
dir,
force,
TintZeroHp,
damageType,
isFlipped: true
);
SpawnGoreGib(
$"{GibFolder}/left_foot",
localPos: new Vector3( 0.6f, 1.8f, 11.5f ) * GibOffsetMultiplier,
localRot: new Angles( 0f, 0f, -90f ),
scaleMultiplier: GibScaleMultiplier,
dir,
force,
TintZeroHp,
damageType
);
SpawnGoreGib(
$"{GibFolder}/left_foot",
localPos: new Vector3( 0.6f, -1.8f, 11.5f ) * GibOffsetMultiplier,
localRot: new Angles( 0f, 0f, -90f ),
scaleMultiplier: GibScaleMultiplier,
dir,
force,
TintZeroHp,
damageType,
isFlipped: true
);
SpawnGoreGib(
$"{GibFolder}/head",
localPos: new Vector3( 0f, 0, 50f ) * GibOffsetMultiplier,
localRot: new Angles( 0f, 0f, 0f ),
scaleMultiplier: GibScaleMultiplier,
dir,
force,
TintZeroHp,
damageType
);
SpawnGibOrgans( dir, force, damageType );
}
protected virtual void SpawnGibOrgans( Vector2 dir, float force, DamageType damageType )
{
SpawnGoreGib(
"organ",
localPos: new Vector3( 0f, 0, 28f ) * GibOffsetMultiplier,
localRot: new Angles( 0f, 0f, 0f ),
scaleMultiplier: GibScaleMultiplier,
dir,
force,
TintZeroHp,
damageType
);
// todo:
//SpawnGoreGib(
// "organ_2",
// localPos: new Vector3( 0f, 0, 24f ),
// localRot: new Angles( 0f, 0f, 0f ),
// scaleMultiplier: 1f,
// dir,
// force,
// TintZeroHp,
// damageType
//);
//SpawnGoreGib(
// "organ_3",
// localPos: new Vector3( 0f, 0, 32f ),
// localRot: new Angles( 0f, 0f, 0f ),
// scaleMultiplier: 1f,
// dir,
// force,
// TintZeroHp,
// damageType
//);
//SpawnGoreGib(
// "organ_4",
// localPos: new Vector3( 0f, 0, 42f ),
// localRot: new Angles( 0f, 0f, 0f ),
// scaleMultiplier: 1f,
// dir,
// force,
// TintZeroHp,
// damageType
//);
//int numEyes = 2;
//for ( int i = 0; i < numEyes; i++ )
//{
// if( Game.Random.Float(0f, 1f) < 0.5f )
// {
// SpawnGoreGib(
// "eyeball",
// localPos: new Vector3( 0f, 0, 52f ),
// localRot: new Angles( 0f, 0f, 0f ),
// scaleMultiplier: 1f,
// dir,
// force,
// TintZeroHp,
// damageType
// );
// }
//}
}
protected void SpawnGoreGib( string name, Vector3 localPos, Rotation localRot, float scaleMultiplier, Vector2 dir, float force, Color color, DamageType damageType, bool isFlipped = false )
{
float chance = Utils.Map( Manager.Instance.NumGibs, 0, 30, 1f, 0f, EasingType.SineOut ) * (damageType == DamageType.Explosion ? Utils.Map( Manager.Instance.NumExplosionKilledEnemies, 0, 9, 1f, 0.1f ) : 1f);
if ( (EnemyType == EnemyType.Zombie || EnemyType == EnemyType.ZombieTemporary) )
chance *= Utils.Map( Manager.Instance.NumEnemies, 40, 200, 1f, 0.5f );
if( OverrideGibChance >= 0f )
chance = OverrideGibChance;
//Log.Info( $"SpawnGoreGib: {name} chance: {chance}" );
if ( Game.Random.Float( 0f, 1f ) > chance )
return;
if ( !(dir.LengthSquared > 0f) )
dir = Utils.GetRandomVector();
// todo: place the gibs properly based on orientation
var pos = WorldPosition + localPos;
var rot = localRot;
var scale = SpawnScale.x * scaleMultiplier;
//var scale = SpawnScale * 1f;
//var gib = GameObject.Clone( $"prefabs/gibs/{name}.prefab", new global::Transform( WorldPosition + offset, new Angles( Game.Random.Float( -5f, 5f ), WorldRotation.Yaw(), Game.Random.Float( -5f, 5f ) ), scale ) );
var gib = GameObject.Clone( $"prefabs/gibs/{name}.prefab", new global::Transform( pos, rot, scale ) );
if( gib == null )
{
Log.Error( $"Failed to spawn gib '{name}' for enemy '{EnemyType}'!" );
return;
}
gib.Name = $"gib - {name}";
var rigidBody = gib.GetComponent<Rigidbody>();
var horizontalForceMultiplier = IsExploding ? 2f : 1f;
var verticalForceMultiplier = IsExploding ? 0.5f : 1f;
rigidBody.Velocity = new Vector3( dir.x * Game.Random.Float( 10f, 170f ) * horizontalForceMultiplier, dir.y * Game.Random.Float( 10f, 170f ) * horizontalForceMultiplier, Game.Random.Float( 100f, 320f ) * verticalForceMultiplier ) * force;
rigidBody.AngularVelocity = new Vector3( Game.Random.Float( -15f, 15f ), Game.Random.Float( -15f, 15f ), Game.Random.Float( -15f, 15f ) ) * force;
var gibFader = gib.GetComponent<GibFader>();
gibFader.Lifetime = OverrideGibLifetime > 0f ? OverrideGibLifetime : Game.Random.Float( 1f, 3.5f );
gibFader.Color = color;
gib.GetComponent<ModelRenderer>().Tint = color;
//gib.SetParent( null, keepWorldPosition: true );
if(isFlipped)
gib.WorldScale = gib.WorldScale.WithY( -gib.WorldScale.y );
}
protected override void Jump( Vector2 targetPos, float height, float lifetime )
{
base.Jump( targetPos, height, lifetime );
PlayJumpAnim();
//SetAnim( "Airborne_Flail_Movement" );
CanAnimate = false;
var playbackRate = Game.Random.Float( 2.5f, 3.5f );
SetPlaybackRate( playbackRate );
HitstopOldPlaybackSpeed = playbackRate;
}
public override void JumpFinish()
{
base.JumpFinish();
if( Manager.Instance.IsGameOver )
{
if( !_isCelebrating )
Celebrate( victory: Manager.Instance.IsBossDead );
return;
}
CanAnimate = true;
PlayWalkAnim();
if ( IsProxy )
return;
_timeSinceDamageTarget = 0f;
if( _jumpFinishDamage > 0f )
{
Damage( _jumpFinishDamage, _jumpFinishDamagePlayerSource, DamageType.JumpFinish, Position2D, force: Vector2.Zero, isCrit: false, shouldFlinch: true );
_jumpFinishDamage = 0f;
_jumpFinishDamagePlayerSource = null;
Stun( _jumpFinishDamagePlayerSource, null, lifetime: _jumpFinishStunTime );
}
}
[Rpc.Owner]
public void DamageOnJumpFinishRpc( float damage, Player playerSource, float stunTime )
{
_jumpFinishDamage = damage;
_jumpFinishDamagePlayerSource = playerSource;
_jumpFinishStunTime = stunTime;
}
public virtual void OnGameOver( bool victory )
{
if ( IsDying || IsInTheAir )
return;
Celebrate( victory );
}
public virtual void Celebrate( bool victory )
{
_isCelebrating = true;
CanAnimate = false;
WorldRotation = new Angles( 0f, WorldRotation.Yaw(), 0f );
WorldPosition = WorldPosition.WithZ( GroundZPos );
ResetMaterial();
PlayCelebrateAnim( victory );
}
protected virtual void PlayCelebrateAnim( bool victory )
{
if ( victory )
{
}
else
{
}
}
protected virtual void HandleExploding()
{
//Gizmo.Draw.Color = Color.Red.WithAlpha(0.6f);
//Gizmo.Draw.LineSphere( WorldPosition, EXPLOSION_RADIUS );
float explodeTime = IsFrozen ? _explodeTime / Utils.Map( TimeScale, 1f, 0f, 1f, 0.75f ) : _explodeTime;
if ( _timeSinceExplodeFlash > Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, EXPLODE_BLINK_DELAY_START, EXPLODE_BLINK_DELAY_END, EasingType.Linear ) )
{
_explodeFlashActive = !_explodeFlashActive;
if ( _explodeFlashActive )
ModelRenderer.SetMaterial( ExplodeFlashMaterial );
else
ResetMaterial();
_timeSinceExplodeFlash = 0f;
}
HandleExplodingPlaybackRate( explodeTime );
float horizontal = 1f + Utils.FastSin( _timeSinceExplodeStart * 16f ) * Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, 0f, 0.25f, EasingType.SineIn );
float vertical = 1f + Utils.FastSin( _timeSinceExplodeStart * 16f ) * Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, 0f, -0.25f, EasingType.SineIn );
WorldScale = new Vector3( _explodeStartScale.x * horizontal, _explodeStartScale.y * horizontal, _explodeStartScale.z * vertical );
if ( IsProxy )
return;
if ( !_hasExploded && _timeSinceExplodeStart > explodeTime && !IsInTheAir )
Explode();
}
protected virtual void HandleExplodingPlaybackRate( float explodeTime )
{
SetPlaybackRate( Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, 1.2f, 0.15f, EasingType.SineOut ) * AnimSpeedModifier );
}
[Rpc.Broadcast( NetFlags.Reliable )]
public void StartExplodingRpc( float time, float radius, float damage, Player player )
{
StartExploding( time, radius, damage, player );
}
protected virtual void StartExploding( float time, float radius, float damage, Player player )
{
if ( IsExploding )
return;
IsExploding = true;
_timeSinceExplodeStart = 0f;
SetAnim( _explodeAnim );
// todo: randomize playback speed so multiple exploders don't sync up the anim
CanAnimate = false;
_timeSinceExplodeFlash = 0f;
_explodeTime = time;
_explodeStartScale = WorldScale;
Manager.Instance.PlaySfxNearby( "exploder_fuse_2", Position2D, pitch: Game.Random.Float( 1.25f, 1.35f ), volume: 1.6f, maxDist: 450f );
if ( IsProxy )
return;
_explosionRadius = radius;
_explosionDamage = damage;
_playerWhoKilledUs = player;
}
public virtual void Explode()
{
RepelOptions options = RepelOptions.RepelPlayers | RepelOptions.DamagePlayers | RepelOptions.RepelEnemies | RepelOptions.RepelItems;
if(_explosionDamagesEnemies )
options |= RepelOptions.DamageEnemies;
//Manager.Instance.CreateExplosion( Position2D, _explosionRadius, _explosionDamage, repelRadius, force, _playerWhoKilledUs, Color.Red );
//Manager.Instance.CreateExplosionRpc( Position2D, _explosionRadius, _explosionDamage, repelRadius: _explosionRadius * 1.4f, repelForce: _explodeForce, playerSource: _playerWhoKilledUs, enemySource: this, enemyType: EnemyType, color: _explosionColor, options );
Manager.Instance.CreateExplosionRpc( Position2D, _explosionRadius, _explosionDamage, repelRadius: _explosionRadius * 1.4f, repelForce: _explodeForce, playerSource: null, enemySource: this, enemyType: EnemyType, color: _explosionColor, options );
if ( _playerWhoKilledUs.IsValid() )
_playerWhoKilledUs.AddResultsStatRpc( ResultStat.ExplosionsCaused, 1 );
DieRpc( dir: Vector2.Zero, force: 4f, player: _playerWhoKilledUs, damageType: DamageType.Explosion );
}
public override void OnFear()
{
base.OnFear();
if ( IsAttacking )
StopAttacking();
}
[Rpc.Owner]
public void HealRpc( float amount, bool playSfx = false )
{
Heal( amount, playSfx );
}
public void Heal( float amount, bool playSfx = false )
{
Assert.True( !IsProxy );
if ( amount == 0f || Health >= MaxHealth )
return;
float hpMissing = MaxHealth - Health;
float hpRecovered = Math.Min( hpMissing, amount );
HealVfx( hpRecovered, playSfx );
}
[Rpc.Broadcast]
public void HealVfx( float amount, bool playSfx = false )
{
Health += amount;
OnAdjustHealth( amount );
float size = Utils.Map( amount, 1, 30, 1.5f, 2.5f, EasingType.QuadOut );
Manager.Instance.SpawnFloaterNumber( WorldPosition.WithZ( 65f ), amount, new Color( 0.3f, 1f, 0.3f ), size, FloaterType.Heal );
Flash( 0.12f, UnitFlashType.Heal );
if( playSfx )
Manager.Instance.PlaySfxNearby ( "heal", Position2D, pitch: Utils.Map(Health, 0f, MaxHealth, 1.4f, 0.8f), volume: 0.85f, maxDist: 400f );
}
protected virtual void OnAdjustHealth( float amount )
{
}
public override void OnStun()
{
base.OnStun();
SetPlaybackRate( 0f );
}
public override void OnStunFinish()
{
base.OnStunFinish();
SetPlaybackRate( 1f );
_timeSinceDamageTarget = 0f;
}
protected override void OnHitstopStart()
{
HitstopOldPlaybackSpeed = ModelRenderer.PlaybackRate;
SetPlaybackRate( 0f );
}
protected override void OnHitstopUpdate()
{
SetPlaybackRate( 0f );
//Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"{_hitstopTime - _timeSinceHitstop}", new global::Transform( WorldPosition ) );
}
protected override void OnHitstopEnd()
{
if ( IsStunned )
return;
SetPlaybackRate( HitstopOldPlaybackSpeed );
}
protected override void UpdateShaking( float strength )
{
if ( ModelRenderer.IsValid() )
ModelRenderer.LocalPosition = ModelOffset + Rotation.Random.Forward * strength;
}
protected override void StopShaking()
{
base.StopShaking();
if ( ModelRenderer.IsValid() )
ModelRenderer.LocalPosition = ModelOffset;
}
protected override void ArmorFlinch( int amount, Vector2 flinchDir )
{
var shakeStrength = Utils.Map( amount, 1, 25, 3f, 5f, EasingType.Linear );
Shake( startStrength: shakeStrength, endStrength: 0f, time: 0.1f );
}
[Rpc.Broadcast( NetFlags.Reliable )]
public void Teleport( Vector2 targetPos )
{
targetPos = Manager.Instance.ClampPosToBounds( targetPos );
CreateTeleportParticle( Position2D, Game.Random.Float( 30f, 35f ), new Color( 0.1f, 0.1f, 1f ), 0.6f );
CreateTeleportParticle( targetPos, Game.Random.Float( 50f, 55f ), new Color( 0.1f, 0.1f, 1f ), 0.7f );
Manager.Instance.PlaySfxNearbyRpc( "blink.start", Position2D, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 400f );
Manager.Instance.PlaySfxNearbyRpc( "blink.end", targetPos, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 400f );
if ( IsProxy )
return;
Position2D = targetPos;
Transform.ClearInterpolation();
}
}