Files

2426 lines
85 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Player;
using BumpCombat.Progression;
using UnityEngine;
namespace BumpCombat.Enemies
{
public interface IEnemyDamageGate
{
bool IsDamageBlocked { get; }
float FilterDamage(float currentHealth, float requestedDamage);
}
public interface IEnemyAttackPatternGate
{
bool CanUseAttackPattern(int patternIndex);
void NotifyAttackPatternUsed(int patternIndex);
}
public interface IEnemyActionGate
{
bool IsAttackLocked { get; }
}
public enum EnemyArtifactContactResult
{
Ignored,
Shielded,
Groggy,
}
public enum EnemyArtifactHitResult
{
Rejected,
Shielded,
DamageApplied,
}
public enum EnemyState
{
Spawn,
Chase,
Warning,
Active,
Recovery,
Dead,
}
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D), typeof(SpriteRenderer))]
[RequireComponent(typeof(EnemyAttack))]
[RequireComponent(typeof(ArtifactStatusVisual))]
[RequireComponent(typeof(EnemyModel))]
public sealed class EnemyController : MonoBehaviour
{
private static readonly ArtifactColor[] ShieldColorOrder =
{
ArtifactColor.Green,
ArtifactColor.Red,
ArtifactColor.Blue,
};
private static readonly int IsMovingParameter = Animator.StringToHash("IsMoving");
private static readonly int IsAttackingParameter = Animator.StringToHash("IsAttacking");
private static readonly int HurtParameter = Animator.StringToHash("Hurt");
private static readonly int DeathState = Animator.StringToHash("Base Layer.Death");
private static readonly Collider2D[] SeparationBuffer = new Collider2D[16];
private static Sprite launchWhiteSprite;
private static int nextSpawnLifetimeIdentity;
[SerializeField] private EnemyDefinition definition;
[SerializeField] private ExperienceOrb experienceOrbPrefab;
private float separationRadius => definition != null
? definition.SeparationRadius
: GameplayConstants.Current.Enemies.DefaultSeparationRadius;
private float separationStrength => definition != null
? definition.SeparationStrength
: GameplayConstants.Current.Enemies.DefaultSeparationStrength;
private Rigidbody2D body;
private Collider2D bodyCollider;
private SpriteRenderer spriteRenderer;
private Animator animator;
private EnemyAttack enemyAttack;
private EnemyModel enemyModel;
private ArtifactStatusVisual statusVisual;
private ActiveArtifactController activeArtifactController;
private Transform player;
private PlayerHealth playerHealth;
private Vector2 lockedDirection;
private Vector2 capturedTarget;
private Vector2 knockbackVelocity;
private float stateTimer;
private float stunTimer;
private float igniteRemaining;
private float igniteNextTickIn;
private float igniteTickInterval;
private float igniteTickDamage;
private float shockRemaining;
private float shockIncrease;
private float vulnerabilityRemaining;
private float vulnerabilityIncrease;
private bool ownsAttackToken;
private bool registeredAlive;
private float nextSeparationRefreshTime;
private Vector2 cachedSeparation;
private RunTimedEvent? runEventRole;
private int experienceValueOverride = -1;
private Coroutine launchVisualCoroutine;
private GameObject airborneVisualObject;
private GameObject launchShadowObject;
private Vector2 crowdApproachOffset;
private RunEventEnemyTuning runEventTuning;
private int completedAttacksInSequence;
private int remainingBackHitsToInterrupt;
private bool attackAnimationStarted;
private Vector2 knockbackStartPosition;
private bool reportedKnockback;
private readonly Dictionary<string, int> spriteVisibilityLeaseReasons = new();
private bool launchSpriteLease;
private int attackPatternIndex;
private IEnemyDamageGate damageGate;
private IEnemyAttackPatternGate attackPatternGate;
private IEnemyActionGate actionGate;
private bool summoned;
private bool phaseSummon;
private bool summonedEventLike;
private EnemyController summonOwner;
private bool groggyTierConfigured;
private RunTimedEvent groggyTier;
private float configuredGroggyDuration;
private int requiredGroggyDistinctArtifacts;
private int requiredGroggyContactsPerArtifact;
private int groggyContactCount;
private ArtifactColor shieldColor = ArtifactColor.Green;
private bool initialShieldColorSelected;
private bool isGroggy;
private bool lastArtifactContactCounted;
private float groggyRemaining;
private int legacyArtifactCastIdentity;
private readonly HashSet<string> recentArtifactContactKeys = new(StringComparer.Ordinal);
private readonly Queue<string> recentArtifactContactOrder = new();
private const int MaximumRememberedArtifactContacts = 128;
public static int AliveCount { get; private set; }
public static int CrowdAliveCount { get; private set; }
public static int NormalAliveCount { get; private set; }
public int SpawnLifetimeIdentity { get; private set; }
public EnemyDefinition Definition => definition;
public EnemyState State { get; private set; }
public float StateTimeRemaining => Mathf.Max(0f, stateTimer);
public float StateDuration { get; private set; }
public float StateNormalizedTime => StateDuration <= 0.0001f
? 1f
: Mathf.Clamp01(1f - stateTimer / StateDuration);
public Vector2 FacingDirection { get; private set; } = Vector2.left;
public float MaximumHealth => EnsureEnemyModel().MaxHealth;
public float CurrentHealth { get; private set; }
public float LastAppliedDamage { get; private set; }
public float LastReportedDamage { get; private set; }
public int CurrentAttackPatternIndex => attackPatternIndex;
public EnemyAttackPattern CurrentAttackPattern => definition != null
? definition.GetAttackPattern(attackPatternIndex)
: default;
public bool IsDead => State == EnemyState.Dead;
public bool IsDamageInvulnerable => !IsDead
&& ((groggyTierConfigured && !isGroggy)
|| (damageGate != null && damageGate.IsDamageBlocked));
public bool IsGroggy => isGroggy;
public float GroggyTimeRemaining => Mathf.Max(0f, groggyRemaining);
public float GroggyProgress => GroggyRequiredContactCount <= 0
? 0f
: Mathf.Clamp01(
GroggyQualifyingContactCount
/ (float)GroggyRequiredContactCount);
public int GroggyQualifyingContactCount
{
get
{
return Mathf.Clamp(
groggyContactCount,
0,
GroggyRequiredContactCount);
}
}
public int GroggyContactCount => GroggyQualifyingContactCount;
public int GroggyRequiredContactCount => requiredGroggyDistinctArtifacts
* requiredGroggyContactsPerArtifact;
public int GroggyDistinctArtifactCount => groggyContactCount > 0 ? 1 : 0;
public int GroggyRequiredDistinctArtifactCount => requiredGroggyDistinctArtifacts;
public ArtifactColor ShieldColor => shieldColor;
public bool LastArtifactContactCounted => lastArtifactContactCounted;
public int ShieldHitRequirement => GroggyRequiredContactCount;
public int ShieldHitsRemaining => IsShielded
? Mathf.Max(0, ShieldHitRequirement - groggyContactCount)
: 0;
public bool IsShielded => groggyTierConfigured
&& !IsDead
&& !isGroggy
&& (damageGate == null || !damageGate.IsDamageBlocked);
public bool IsGroggyTierGated => groggyTierConfigured;
public RunTimedEvent? GroggyTier => groggyTierConfigured
? groggyTier
: null;
public bool IsStunned => stunTimer > 0f;
public bool IsIgnited => igniteRemaining > 0f;
public bool IsShocked => shockRemaining > 0f;
public bool IsBumpVulnerable => vulnerabilityRemaining > 0f;
public float IgniteRemaining => igniteRemaining;
public float ShockRemaining => shockRemaining;
public float BumpVulnerabilityRemaining => vulnerabilityRemaining;
public float ShockIncrease => shockIncrease;
public float BumpVulnerabilityIncrease => vulnerabilityIncrease;
public Vector2 GroundAnchorPosition => body != null
? body.position
: transform.position;
public bool IsKnockbackActive => knockbackVelocity.sqrMagnitude > 0.0025f;
public bool IsLaunchVisualActive => launchVisualCoroutine != null;
public bool IsAttackSequenceInProgress => State == EnemyState.Warning
|| State == EnemyState.Active
|| State == EnemyState.Recovery;
public bool IsCrowd => definition != null && definition.IsCrowd;
public bool IsSummoned => summoned;
public bool IsPhaseSummon => phaseSummon;
public bool IsEventEnemy => runEventRole.HasValue || summonedEventLike;
public EnemyController SummonOwner => summonOwner;
public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon;
public bool HasContactDamage => definition != null && definition.HasContactDamage;
public bool IsRangedAttackStartAllowed => definition == null
|| !definition.IsRanged
|| CanStartRangedAttack();
public RunTimedEvent? RunEventRole => runEventRole;
public EnemyAttackShape AttackShape => definition != null
? CurrentAttackPattern.AttackShape
: EnemyAttackShape.Box;
public float MoveSpeed => EnsureEnemyModel().MoveSpeed;
public float AttackDamage => EnsureEnemyModel().CalculatePatternDamage(
CurrentAttackPattern.DamageMultiplier,
IsEventEnemy);
public float WarningDuration => GetEventAdjustedValue(
definition != null ? CurrentAttackPattern.WarningDuration : 0f,
runEventTuning.WarningDurationMultiplier);
public float ActiveDuration => GetEventAdjustedValue(
definition != null ? CurrentAttackPattern.ActiveDuration : 0f,
runEventTuning.ActiveDurationMultiplier);
public float RecoveryDuration => GetEventAdjustedValue(
definition != null ? CurrentAttackPattern.RecoveryDuration : 0f,
runEventTuning.RecoveryDurationMultiplier);
public float AttackRange => GetEventAdjustedValue(
definition != null ? CurrentAttackPattern.AttackRange : 0f,
CurrentAttackPattern.GeometryScaleMode
== EnemyAttackGeometryScaleMode.RootScale
? 1f
: runEventTuning.AttackRangeMultiplier)
* (definition != null
&& CurrentAttackPattern.GeometryScaleMode
== EnemyAttackGeometryScaleMode.RootScale
? EnemyAttack.GetRootScale(transform)
: 1f);
public float AttackLength => GetRootScaledGeometryValue(
definition != null ? CurrentAttackPattern.AttackLength : 0f,
runEventTuning.AttackLengthMultiplier);
public float AttackWidth => GetRootScaledGeometryValue(
definition != null ? CurrentAttackPattern.AttackWidth : 0f,
runEventTuning.AttackWidthMultiplier);
public float AttackRadius => GetRootScaledGeometryValue(
definition != null ? CurrentAttackPattern.AttackRadius : 0f,
runEventTuning.AttackRadiusMultiplier);
public int AttacksPerSequence => IsEventEnemy
? runEventTuning.AttacksPerSequence
: 1;
public int BackHitsToInterrupt => IsEventEnemy
? runEventTuning.BackHitsToInterrupt
: 1;
public float KnockbackMultiplier => IsEventEnemy
? runEventTuning.KnockbackMultiplier
: 1f;
public bool CanBeLaunched => !IsEventEnemy
|| runEventTuning.CanBeLaunched;
public float ContactDamage => AttackDamage;
public float PlayerKnockbackDistance =>
definition != null ? GetPlayerKnockbackDistance(definition.Kind) : 0f;
private bool UsesHorizontalRootAttackVisual => definition != null
&& CurrentAttackPattern.DirectionMode
== EnemyAttackDirectionMode.HorizontalRoot
&& (CurrentAttackPattern.AttackShape == EnemyAttackShape.Box
|| CurrentAttackPattern.AttackShape == EnemyAttackShape.Cone);
private float GetRootScaledGeometryValue(
float authoredValue,
float roleMultiplier)
{
if (definition != null
&& CurrentAttackPattern.GeometryScaleMode
== EnemyAttackGeometryScaleMode.RootScale)
{
return Mathf.Max(0f, authoredValue)
* EnemyAttack.GetRootScale(transform);
}
return GetEventAdjustedValue(authoredValue, roleMultiplier);
}
public event Action<EnemyController> OnDied;
public event Action<float, float> OnDamageApplied;
public void ConfigureRunEventEnemy(
RunTimedEvent role,
RunEventEnemyTuning tuning)
{
runEventRole = role;
runEventTuning = tuning;
experienceValueOverride = tuning.ExperienceValue;
ConfigureEnemyModel();
ConfigureGroggyTier(role);
InitializeInitialShieldColor();
transform.localScale *= tuning.ScaleMultiplier;
if (spriteRenderer != null)
{
spriteRenderer.color = tuning.Tint;
}
StageEnemyEffectVisual.SetProtection(gameObject, true);
Physics2D.SyncTransforms();
KeepBodyInsideArena();
}
public void ConfigureSummonedEnemy(
EnemyController owner,
RunEventEnemyTuning tuning,
bool phaseProtected = true)
{
runEventRole = null;
summoned = true;
phaseSummon = phaseProtected;
summonedEventLike = phaseProtected;
summonOwner = owner;
runEventTuning = tuning;
experienceValueOverride = tuning.ExperienceValue;
ConfigureEnemyModel();
if (phaseProtected)
{
RunTimedEvent? inferredTier = TryInferGroggyTier(tuning.PrefabKind);
inferredTier ??= owner != null ? owner.GroggyTier : null;
if (inferredTier.HasValue)
{
ConfigureGroggyTier(inferredTier.Value);
InitializeInitialShieldColor();
StageEnemyEffectVisual.SetProtection(gameObject, true);
}
}
transform.localScale *= tuning.ScaleMultiplier;
if (spriteRenderer != null)
{
spriteRenderer.color = tuning.Tint;
}
Physics2D.SyncTransforms();
KeepBodyInsideArena();
}
#if UNITY_EDITOR
public void Configure(EnemyDefinition enemyDefinition, ExperienceOrb orbPrefab)
{
definition = enemyDefinition;
experienceOrbPrefab = orbPrefab;
ConfigureEnemyModel();
}
#endif
private void Awake()
{
nextSpawnLifetimeIdentity = nextSpawnLifetimeIdentity == int.MaxValue
? 1
: nextSpawnLifetimeIdentity + 1;
SpawnLifetimeIdentity = nextSpawnLifetimeIdentity;
body = GetComponent<Rigidbody2D>();
// All enemy movement is planar and driven by MovePosition. Keep
// the imported prefab invariant here as well so dynamically
// created enemies cannot acquire a physics roll from contacts.
if (body != null)
{
body.constraints |= RigidbodyConstraints2D.FreezeRotation;
body.angularVelocity = 0f;
}
bodyCollider = GetComponent<Collider2D>();
spriteRenderer = GetComponent<SpriteRenderer>();
animator = GetComponent<Animator>();
enemyAttack = GetComponent<EnemyAttack>();
ConfigureEnemyModel();
MonoBehaviour[] behaviours = GetComponents<MonoBehaviour>();
for (int i = 0; i < behaviours.Length; i++)
{
if (behaviours[i] is IEnemyDamageGate gate)
{
damageGate = gate;
}
if (behaviours[i] is IEnemyAttackPatternGate patternGate)
{
attackPatternGate = patternGate;
}
if (behaviours[i] is IEnemyActionGate actionGateBehaviour)
{
actionGate = actionGateBehaviour;
}
}
statusVisual = GetComponent<ArtifactStatusVisual>();
if (statusVisual == null)
{
statusVisual = gameObject.AddComponent<ArtifactStatusVisual>();
}
if (definition != null
&& definition.AttackPatternCount > 0
&& GetComponent<StageAttackAnimationVisual>() == null)
{
gameObject.AddComponent<StageAttackAnimationVisual>();
}
int crowdSlot = GetInstanceID() & int.MaxValue;
EnemyConstants enemyConstants = GameplayConstants.Current.Enemies;
float angle = crowdSlot % enemyConstants.CrowdApproachSlotCount
* (Mathf.PI * 2f / enemyConstants.CrowdApproachSlotCount);
float radius = enemyConstants.CrowdApproachBaseRadius
+ crowdSlot / enemyConstants.CrowdApproachSlotCount
% enemyConstants.CrowdApproachRingCount
* enemyConstants.CrowdApproachRingSpacing;
crowdApproachOffset = new Vector2(
Mathf.Cos(angle),
Mathf.Sin(angle)) * radius;
InitializeGroggyTierFromDefinition();
}
private void OnEnable()
{
if (enemyModel != null)
{
enemyModel.OnStatChanged += HandleCharacterStatChanged;
}
ClampCurrentHealthToMaximum();
nextSeparationRefreshTime =
Time.fixedTime + Mathf.Abs(GetInstanceID() % 10) * 0.01f;
if (!registeredAlive)
{
registeredAlive = true;
AliveCount++;
if (IsCrowd)
{
CrowdAliveCount++;
}
else
{
NormalAliveCount++;
}
}
KeepBodyInsideArena();
}
private void Start()
{
InitializeGroggyTierFromDefinition();
KeepBodyInsideArena();
if (definition == null)
{
enabled = false;
return;
}
PlayerController playerController = FindAnyObjectByType<PlayerController>();
if (playerController == null)
{
enabled = false;
return;
}
player = playerController.transform;
playerHealth = playerController.GetComponent<PlayerHealth>();
activeArtifactController = playerController.GetComponent<ActiveArtifactController>();
InitializeInitialShieldColor();
CurrentHealth = MaximumHealth;
float summonDuration = summoned ? GetSummonAnimationDuration() : 0f;
bool playSummonAnimation = summonDuration > 0f;
EnterState(
EnemyState.Spawn,
playSummonAnimation
? summonDuration
: GameplayConstants.Current.Enemies.SummonedEnemyNoAnimationLockDuration);
if (playSummonAnimation)
{
PlaySummonAnimation();
}
}
private void LateUpdate()
{
if (IsDead || body == null || bodyCollider == null)
{
return;
}
// MovePosition is resolved by the physics step, and contacts can
// move a body after FixedUpdate's requested clamp. Correct the
// resulting body AABB here without cancelling knockback or velocity.
KeepBodyInsideArena();
}
private void OnDisable()
{
if (enemyModel != null)
{
enemyModel.OnStatChanged -= HandleCharacterStatChanged;
}
ResetGroggyState(true);
stunTimer = 0f;
ClearTimedEffects();
StopLaunchVisual();
spriteVisibilityLeaseReasons.Clear();
if (spriteRenderer != null)
{
spriteRenderer.enabled = true;
}
ReleaseAttackToken();
if (registeredAlive)
{
registeredAlive = false;
AliveCount = Mathf.Max(0, AliveCount - 1);
if (IsCrowd)
{
CrowdAliveCount = Mathf.Max(0, CrowdAliveCount - 1);
}
else
{
NormalAliveCount = Mathf.Max(0, NormalAliveCount - 1);
}
}
}
private void FixedUpdate()
{
UpdateTimedEffects(Time.fixedDeltaTime);
if (isGroggy)
{
stunTimer = 0f;
body.linearVelocity = Vector2.zero;
knockbackVelocity = Vector2.zero;
SetMoving(false);
SetAttacking(false);
groggyRemaining = Mathf.Max(
0f,
groggyRemaining - Time.fixedDeltaTime);
if (groggyRemaining <= 0f)
{
EndGroggyCycle();
}
return;
}
if (player == null)
{
return;
}
if (knockbackVelocity.sqrMagnitude > 0.0025f)
{
body.linearVelocity = Vector2.zero;
body.MovePosition(ClampEnemyPosition(
body.position + knockbackVelocity * Time.fixedDeltaTime));
enemyAttack.RebaseActiveTipAfterExternalMotion();
knockbackVelocity = Vector2.MoveTowards(
knockbackVelocity,
Vector2.zero,
GameplayConstants.Current.Enemies.KnockbackDeceleration
* Time.fixedDeltaTime);
if (knockbackVelocity.sqrMagnitude <= 0.0025f && !reportedKnockback)
{
reportedKnockback = true;
CombatEvents.RaiseKnockbackCompleted(
gameObject,
knockbackStartPosition,
body.position);
}
return;
}
if (State == EnemyState.Dead)
{
return;
}
if (stunTimer > 0f)
{
stunTimer = Mathf.Max(
0f,
stunTimer - Time.fixedDeltaTime);
body.linearVelocity = Vector2.zero;
SetMoving(false);
SetAttacking(false);
return;
}
if (!AllowsStateMovement(State, AttackShape))
{
body.linearVelocity = Vector2.zero;
}
stateTimer -= Time.fixedDeltaTime;
switch (State)
{
case EnemyState.Spawn:
if (stateTimer <= 0f)
{
EnterState(EnemyState.Chase, 0f);
}
break;
case EnemyState.Chase:
if (actionGate != null && actionGate.IsAttackLocked)
{
body.linearVelocity = Vector2.zero;
SetMoving(false);
SetAttacking(false);
}
else
{
UpdateChase();
}
break;
case EnemyState.Warning:
if (ShouldStartAttackAnimation(
stateTimer,
CurrentAttackPattern.AnimationLeadTime,
attackAnimationStarted))
{
attackAnimationStarted = true;
StartAttackAnimation();
}
if (stateTimer <= 0f)
{
attackAnimationStarted = true;
EnterState(EnemyState.Active, ActiveDuration);
enemyAttack.Activate(0f);
}
break;
case EnemyState.Active:
UpdateActiveMovement();
enemyAttack.TickActive(
ActiveDuration - Mathf.Max(0f, stateTimer));
if (stateTimer <= 0f)
{
enemyAttack.EndAttack();
completedAttacksInSequence++;
bool hasFollowUp =
completedAttacksInSequence < AttacksPerSequence;
float recovery = RecoveryDuration
* (hasFollowUp
? runEventTuning.BetweenAttacksRecoveryMultiplier
: 1f);
EnterState(EnemyState.Recovery, recovery);
}
break;
case EnemyState.Recovery:
if (stateTimer <= 0f)
{
if (completedAttacksInSequence < AttacksPerSequence)
{
BeginFollowUpAttack();
}
else
{
ReleaseAttackToken();
RaiseEnemyStaggerClearedIfNeeded();
EnterState(EnemyState.Chase, 0f);
}
}
break;
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
TryDealContactDamage(collision.collider);
}
private void OnTriggerEnter2D(Collider2D other)
{
if (IsCrowd)
{
TryDealContactDamage(other);
}
}
private void OnTriggerStay2D(Collider2D other)
{
if (IsCrowd)
{
TryDealContactDamage(other);
}
}
private void TryDealContactDamage(Collider2D other)
{
if (!HasContactDamage
|| IsDead
|| !AllowsContactDamage(State, AttackShape))
{
return;
}
PlayerHealth health = other.GetComponentInParent<PlayerHealth>();
if (health == null)
{
return;
}
Vector2 enemyToPlayer =
((Vector2)health.transform.position - (Vector2)transform.position).normalized;
if (BumpCombatMath.ClassifySide(FacingDirection, enemyToPlayer) == HitSide.Front)
{
health.TryTakeDamage(ContactDamage, enemyToPlayer, PlayerKnockbackDistance);
}
}
public bool TryTakeDamage(
float damage,
Vector2 knockbackDirection,
float knockbackForce,
bool playHurtAnimation = true,
bool isNormalBump = false)
{
return TryTakeDamageWithEffects(
damage,
knockbackDirection,
knockbackForce,
playHurtAnimation,
isNormalBump,
IsShocked,
IsBumpVulnerable,
out _);
}
private bool TryTakeDamageWithEffects(
float damage,
Vector2 knockbackDirection,
float knockbackForce,
bool playHurtAnimation,
bool isNormalBump,
bool isShockedAtHit,
bool isBumpVulnerableAtHit,
out bool noDamageAfterEffects)
{
noDamageAfterEffects = false;
if (IsDead || damage <= 0f)
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return false;
}
if (groggyTierConfigured && !isGroggy)
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return false;
}
float damageAfterModifiers = enemyModel != null
? enemyModel.CalculateIncomingDamage(damage)
: damage;
float calculatedDamage = DamageCalculator.ApplyDamageTakenEffects(
damageAfterModifiers,
shockIncrease,
isShockedAtHit,
vulnerabilityIncrease,
isNormalBump && isBumpVulnerableAtHit);
if (calculatedDamage <= 0f || float.IsNaN(calculatedDamage))
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
noDamageAfterEffects = calculatedDamage <= 0f;
return false;
}
float damageTaken = calculatedDamage;
if (damageGate != null)
{
damageTaken = damageGate.FilterDamage(CurrentHealth, damageTaken);
}
if (damageTaken <= 0f || float.IsNaN(damageTaken))
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return false;
}
LastReportedDamage = damageTaken < calculatedDamage - 0.0001f
? damageTaken
: calculatedDamage;
float healthBeforeDamage = CurrentHealth;
CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken);
LastAppliedDamage = healthBeforeDamage - CurrentHealth;
OnDamageApplied?.Invoke(healthBeforeDamage, LastAppliedDamage);
bool damageReactionBlocked = damageGate != null
&& damageGate.IsDamageBlocked;
if (CurrentHealth <= 0f)
{
Die();
}
else if (damageReactionBlocked)
{
// A damage callback may have started Necromancer's protected
// desperation phase. Do not append the opening hit's
// knockback or hurt reaction after that transition.
knockbackVelocity = Vector2.zero;
body.linearVelocity = Vector2.zero;
}
else
{
if (knockbackVelocity.sqrMagnitude <= 0.0025f)
{
knockbackStartPosition = body.position;
}
knockbackVelocity += knockbackDirection.normalized
* knockbackForce
* KnockbackMultiplier;
reportedKnockback = false;
if (playHurtAnimation && animator != null)
{
animator.SetTrigger(HurtParameter);
}
}
return true;
}
public EnemyArtifactContactResult RegisterArtifactContact(
ArtifactColor artifactColor,
int castIdentity)
{
lastArtifactContactCounted = false;
if (IsDead || !groggyTierConfigured)
{
return IsDead
? EnemyArtifactContactResult.Ignored
: EnemyArtifactContactResult.Groggy;
}
castIdentity = NormalizeCastIdentity(castIdentity);
string key = castIdentity + ":" + (int)artifactColor;
bool isNewContact = recentArtifactContactKeys.Add(key);
if (isNewContact)
{
recentArtifactContactOrder.Enqueue(key);
while (recentArtifactContactOrder.Count
> MaximumRememberedArtifactContacts)
{
string oldest = recentArtifactContactOrder.Dequeue();
recentArtifactContactKeys.Remove(oldest);
}
}
// Remember contacts made during groggy as well. Delayed ticks
// from that cast must not reopen the next shield cycle.
if (isGroggy)
{
return EnemyArtifactContactResult.Groggy;
}
if (damageGate != null && damageGate.IsDamageBlocked)
{
return EnemyArtifactContactResult.Ignored;
}
if (!isNewContact)
{
return EnemyArtifactContactResult.Ignored;
}
if (artifactColor != shieldColor)
{
return EnemyArtifactContactResult.Shielded;
}
lastArtifactContactCounted = true;
groggyContactCount = Mathf.Min(
requiredGroggyContactsPerArtifact,
groggyContactCount + 1);
int current = GroggyQualifyingContactCount;
int required = GroggyRequiredContactCount;
if (HasReachedGroggyThreshold())
{
BeginGroggyCycle();
}
else
{
CombatEvents.RaiseEnemyStagger(
gameObject,
current,
required,
false);
}
return EnemyArtifactContactResult.Shielded;
}
public EnemyArtifactHitResult TryTakeArtifactHit(
float damage,
Vector2 knockbackDirection,
float knockbackForce,
ArtifactColor artifactColor,
int castIdentity,
bool playHurtAnimation = true)
{
lastArtifactContactCounted = false;
if (IsDead)
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return EnemyArtifactHitResult.Rejected;
}
if (groggyTierConfigured)
{
EnemyArtifactContactResult contact = RegisterArtifactContact(
artifactColor,
castIdentity);
if (contact == EnemyArtifactContactResult.Ignored)
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return EnemyArtifactHitResult.Rejected;
}
if (contact == EnemyArtifactContactResult.Shielded)
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return EnemyArtifactHitResult.Shielded;
}
}
if (damage <= 0f)
{
LastAppliedDamage = 0f;
LastReportedDamage = 0f;
return EnemyArtifactHitResult.DamageApplied;
}
return TryTakeDamage(
damage,
knockbackDirection,
knockbackForce,
playHurtAnimation,
false)
? EnemyArtifactHitResult.DamageApplied
: EnemyArtifactHitResult.Rejected;
}
public bool TryApplyArtifactContact(
ArtifactColor artifactColor,
int castIdentity)
{
return RegisterArtifactContact(artifactColor, castIdentity)
!= EnemyArtifactContactResult.Ignored;
}
public void EndGroggyForProtection()
{
if (!groggyTierConfigured)
{
return;
}
if (isGroggy)
{
EndGroggyCycle();
return;
}
groggyContactCount = 0;
CombatEvents.RaiseEnemyStaggerCleared(gameObject);
}
public bool BeginGroggyAfterProtection()
{
if (!groggyTierConfigured
|| IsDead
|| isGroggy
|| (damageGate != null && damageGate.IsDamageBlocked))
{
return false;
}
groggyContactCount = 0;
BeginGroggyCycle();
return isGroggy;
}
public bool ApplyIgnite(
float duration,
float tickInterval,
float damagePerTick)
{
if (IsDead
|| duration <= 0f
|| tickInterval <= 0f
|| damagePerTick <= 0f)
{
return false;
}
bool wasInactive = igniteRemaining <= 0f;
igniteRemaining = Mathf.Max(igniteRemaining, duration);
igniteTickDamage = Mathf.Max(igniteTickDamage, damagePerTick);
if (wasInactive)
{
igniteTickInterval = tickInterval;
igniteNextTickIn = tickInterval;
}
return true;
}
public bool ApplyShock(
float duration,
float damageIncrease)
{
if (IsDead || duration <= 0f || damageIncrease <= 0f)
{
return false;
}
shockRemaining = Mathf.Max(shockRemaining, duration);
shockIncrease = Mathf.Max(shockIncrease, damageIncrease);
return true;
}
public bool ApplyBumpVulnerability(
float duration,
float damageIncrease)
{
if (IsDead || duration <= 0f || damageIncrease <= 0f)
{
return false;
}
vulnerabilityRemaining = Mathf.Max(vulnerabilityRemaining, duration);
vulnerabilityIncrease = Mathf.Max(
vulnerabilityIncrease,
damageIncrease);
return true;
}
private void UpdateTimedEffects(float deltaTime)
{
if (IsDead || deltaTime <= 0f)
{
return;
}
if (igniteRemaining <= 0f)
{
AdvanceDamageTakenEffects(deltaTime);
return;
}
float remainingBeforeTick = igniteRemaining;
float nextTickIn = igniteNextTickIn;
igniteRemaining = Mathf.Max(0f, igniteRemaining - deltaTime);
while (nextTickIn <= deltaTime + 0.0001f
&& nextTickIn <= remainingBeforeTick + 0.0001f)
{
bool isShockedAtTick = shockRemaining
> Mathf.Max(0f, nextTickIn) + 0.0001f;
bool damageApplied = TryTakeDamageWithEffects(
igniteTickDamage,
Vector2.zero,
0f,
false,
false,
isShockedAtTick,
false,
out bool noDamageAfterEffects);
if ((!damageApplied && !noDamageAfterEffects) || IsDead)
{
break;
}
nextTickIn += igniteTickInterval;
}
if (IsDead)
{
return;
}
AdvanceDamageTakenEffects(deltaTime);
igniteNextTickIn = nextTickIn - deltaTime;
if (igniteRemaining <= 0f)
{
igniteRemaining = 0f;
igniteNextTickIn = 0f;
igniteTickInterval = 0f;
igniteTickDamage = 0f;
}
}
private void AdvanceDamageTakenEffects(float deltaTime)
{
shockRemaining = Mathf.Max(0f, shockRemaining - deltaTime);
if (shockRemaining <= 0f)
{
shockIncrease = 0f;
}
vulnerabilityRemaining = Mathf.Max(
0f,
vulnerabilityRemaining - deltaTime);
if (vulnerabilityRemaining <= 0f)
{
vulnerabilityIncrease = 0f;
}
}
private void ClearTimedEffects()
{
igniteRemaining = 0f;
igniteNextTickIn = 0f;
igniteTickInterval = 0f;
igniteTickDamage = 0f;
shockRemaining = 0f;
shockIncrease = 0f;
vulnerabilityRemaining = 0f;
vulnerabilityIncrease = 0f;
}
private bool HasReachedGroggyThreshold()
{
return groggyContactCount >= GroggyRequiredContactCount;
}
private void InitializeGroggyTierFromDefinition()
{
if (groggyTierConfigured || definition == null)
{
return;
}
RunTimedEvent? inferred = TryInferGroggyTier(definition.Kind);
if (inferred.HasValue)
{
ConfigureGroggyTier(inferred.Value);
StageEnemyEffectVisual.SetProtection(gameObject, true);
}
}
private void ConfigureGroggyTier(RunTimedEvent tier)
{
groggyTierConfigured = true;
groggyTier = tier;
initialShieldColorSelected = false;
shieldColor = ArtifactColor.Green;
EnemyConstants tuning = GameplayConstants.Current.Enemies;
switch (tier)
{
case RunTimedEvent.Elite:
configuredGroggyDuration = Mathf.Max(0f, tuning.EliteGroggyDuration);
requiredGroggyDistinctArtifacts = 1;
requiredGroggyContactsPerArtifact = Mathf.Max(
1,
tuning.EliteGroggyRequiredMatchingHits);
break;
case RunTimedEvent.MidBoss:
configuredGroggyDuration = Mathf.Max(0f, tuning.MidBossGroggyDuration);
requiredGroggyDistinctArtifacts = 1;
requiredGroggyContactsPerArtifact = Mathf.Max(
1,
tuning.MidBossGroggyRequiredMatchingHits);
break;
case RunTimedEvent.FinalBoss:
configuredGroggyDuration = Mathf.Max(0f, tuning.FinalBossGroggyDuration);
requiredGroggyDistinctArtifacts = 1;
requiredGroggyContactsPerArtifact = Mathf.Max(
1,
tuning.FinalBossGroggyRequiredMatchingHits);
break;
}
ResetGroggyState(true);
}
private void InitializeInitialShieldColor()
{
if (!groggyTierConfigured || initialShieldColorSelected)
{
return;
}
if (activeArtifactController == null)
{
if (player != null)
{
activeArtifactController =
player.GetComponent<ActiveArtifactController>();
}
else
{
activeArtifactController =
FindAnyObjectByType<ActiveArtifactController>();
}
}
if (activeArtifactController == null)
{
// Enemy prefabs can be initialized before the player exists.
// Leave the fallback in place and allow Start to retry once.
shieldColor = ArtifactColor.Green;
return;
}
List<ArtifactColor> ownedColors = new();
for (int i = 0; i < activeArtifactController.OwnedArtifactCount; i++)
{
ActiveArtifactDefinition artifact =
activeArtifactController.GetArtifactAt(i);
if (artifact != null && !ownedColors.Contains(artifact.ArtifactColor))
{
ownedColors.Add(artifact.ArtifactColor);
}
}
shieldColor = ownedColors.Count == 0
? ArtifactColor.Green
: ownedColors[UnityEngine.Random.Range(0, ownedColors.Count)];
initialShieldColorSelected = true;
}
private static RunTimedEvent? TryInferGroggyTier(EnemyKind kind)
{
return kind switch
{
EnemyKind.ArmoredSkeleton
or EnemyKind.Werewolf
or EnemyKind.Werebear => RunTimedEvent.Elite,
EnemyKind.GreatswordSkeleton
or EnemyKind.NecroGolem => RunTimedEvent.MidBoss,
EnemyKind.Necromancer => RunTimedEvent.FinalBoss,
_ => null,
};
}
private int NormalizeCastIdentity(int identity)
{
if (identity > 0)
{
return identity;
}
legacyArtifactCastIdentity++;
if (legacyArtifactCastIdentity <= 0)
{
legacyArtifactCastIdentity = 1;
}
return legacyArtifactCastIdentity;
}
private void BeginGroggyCycle()
{
if (!groggyTierConfigured
|| isGroggy
|| IsDead)
{
return;
}
isGroggy = true;
groggyRemaining = configuredGroggyDuration;
stunTimer = 0f;
knockbackVelocity = Vector2.zero;
if (body != null)
{
body.linearVelocity = Vector2.zero;
}
enemyAttack?.EndAttack(true);
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
EnterState(EnemyState.Chase, 0f);
SetMoving(false);
SetAttacking(false);
CombatEvents.RaiseEnemyStagger(
gameObject,
GroggyQualifyingContactCount,
GroggyRequiredContactCount,
true);
}
private void EndGroggyCycle()
{
bool wasGroggy = isGroggy;
isGroggy = false;
groggyRemaining = 0f;
stunTimer = 0f;
groggyContactCount = 0;
if (!IsDead)
{
EnterState(EnemyState.Chase, 0f);
}
if (wasGroggy)
{
AdvanceShieldColor();
CombatEvents.RaiseEnemyStaggerCleared(gameObject);
}
}
private void ResetGroggyState(bool clearRememberedContacts)
{
isGroggy = false;
groggyRemaining = 0f;
groggyContactCount = 0;
if (!clearRememberedContacts)
{
return;
}
recentArtifactContactKeys.Clear();
recentArtifactContactOrder.Clear();
legacyArtifactCastIdentity = 0;
}
private void AdvanceShieldColor()
{
if (RunManager.Instance?.IsProductionRun == true)
{
AdvanceShieldColorWithinOwnedColors();
return;
}
shieldColor = shieldColor switch
{
ArtifactColor.Green => ArtifactColor.Red,
ArtifactColor.Red => ArtifactColor.Blue,
_ => ArtifactColor.Green,
};
}
private void AdvanceShieldColorWithinOwnedColors()
{
if (activeArtifactController == null)
{
activeArtifactController = player != null
? player.GetComponent<ActiveArtifactController>()
: FindAnyObjectByType<ActiveArtifactController>();
}
int currentIndex = GetShieldColorOrderIndex(shieldColor);
for (int offset = 1; offset <= ShieldColorOrder.Length; offset++)
{
ArtifactColor candidate = ShieldColorOrder[
(currentIndex + offset) % ShieldColorOrder.Length];
if (OwnsArtifactColor(candidate))
{
shieldColor = candidate;
return;
}
}
// A production run normally has at least one artifact, but retain
// the established fallback if the loadout is temporarily empty.
shieldColor = ArtifactColor.Green;
}
private bool OwnsArtifactColor(ArtifactColor color)
{
if (activeArtifactController == null)
{
return false;
}
for (int i = 0; i < activeArtifactController.OwnedArtifactCount; i++)
{
ActiveArtifactDefinition artifact =
activeArtifactController.GetArtifactAt(i);
if (artifact != null && artifact.ArtifactColor == color)
{
return true;
}
}
return false;
}
private static int GetShieldColorOrderIndex(ArtifactColor color)
{
for (int i = 0; i < ShieldColorOrder.Length; i++)
{
if (ShieldColorOrder[i] == color)
{
return i;
}
}
return 0;
}
public void PlayHurtAnimation()
{
if (animator != null)
{
animator.SetTrigger(HurtParameter);
}
}
public void PlaySummonAnimation()
{
if (animator == null
|| !animator.HasState(0, Animator.StringToHash("Summon")))
{
return;
}
SetMoving(false);
SetAttacking(false);
animator.Play("Summon", 0, 0f);
}
public float GetSummonAnimationDuration()
{
if (animator == null
|| !animator.HasState(0, Animator.StringToHash("Summon"))
|| animator.runtimeAnimatorController == null)
{
return 0f;
}
AnimationClip[] clips =
animator.runtimeAnimatorController.animationClips;
for (int i = 0; i < clips.Length; i++)
{
AnimationClip clip = clips[i];
if (clip != null
&& clip.name.IndexOf("Summon", StringComparison.Ordinal) >= 0)
{
return clip.length;
}
}
return 0f;
}
public bool TryReposition(Vector2 position)
{
if (IsDead)
{
return false;
}
knockbackVelocity = Vector2.zero;
body.linearVelocity = Vector2.zero;
body.position = ClampEnemyPosition(position);
transform.position = body.position;
return true;
}
public bool RetireFromRun(
bool includeProtected = false,
bool suppressFeedback = false)
{
if (IsDead || (IsProtectedFromCleanup && !includeProtected))
{
return false;
}
EnterState(EnemyState.Dead, 0f);
enemyAttack.EndAttack();
ReleaseAttackToken();
bodyCollider.enabled = false;
if (!suppressFeedback)
{
CombatEvents.RaiseEnemyRetired(gameObject);
}
gameObject.SetActive(false);
Destroy(gameObject);
return true;
}
public void CancelCurrentAttack()
{
if (State == EnemyState.Recovery)
{
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
return;
}
if (State != EnemyState.Warning && State != EnemyState.Active)
{
return;
}
enemyAttack.EndAttack(true);
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
EnterState(EnemyState.Recovery, 0f);
}
public bool ApplyStun(float duration)
{
if (IsDead || duration <= 0f)
{
return false;
}
// Stun is a normal hit side effect. During the bounded groggy
// window it must not survive the window or extend it.
if (IsDamageInvulnerable || isGroggy)
{
stunTimer = 0f;
return false;
}
bool cancelledAttack = CanBackHitInterrupt(State);
stunTimer = Mathf.Max(stunTimer, duration);
knockbackVelocity = Vector2.zero;
body.linearVelocity = Vector2.zero;
enemyAttack.EndAttack(true);
ReleaseAttackToken();
completedAttacksInSequence = AttacksPerSequence;
CombatEvents.RaiseEnemyStaggerCleared(gameObject);
EnterState(EnemyState.Recovery, 0f);
if (cancelledAttack)
{
CombatEvents.RaiseAttackCancelled(gameObject, true);
}
// Publish the effective remaining duration so reapplication never shortens the marker.
CombatEvents.RaiseStunApplied(gameObject, stunTimer);
return true;
}
public bool TryInterruptAttackFromBackHit()
{
if (IsDamageInvulnerable || !CanBackHitInterrupt(State))
{
return false;
}
remainingBackHitsToInterrupt = Mathf.Max(
0,
remainingBackHitsToInterrupt - 1);
bool broke = remainingBackHitsToInterrupt == 0;
if (IsEventEnemy && !groggyTierConfigured)
{
int maximum = Mathf.Max(1, BackHitsToInterrupt);
CombatEvents.RaiseEnemyStagger(
gameObject,
maximum - remainingBackHitsToInterrupt,
maximum,
broke);
}
return broke && TryInterruptAttack();
}
public bool TryInterruptAttackFromLaunch()
{
return TryInterruptAttack();
}
private bool TryInterruptAttack()
{
if (IsDamageInvulnerable || !CanBackHitInterrupt(State))
{
return false;
}
enemyAttack.EndAttack(true);
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
RaiseEnemyStaggerClearedIfNeeded();
EnterState(EnemyState.Recovery, RecoveryDuration);
CombatEvents.RaiseAttackCancelled(gameObject, false);
return true;
}
public void PlayLaunchVisual(
Vector2 launchDirection,
float launchHeight,
float duration)
{
if (spriteRenderer == null || duration <= 0f)
{
return;
}
StopLaunchVisual();
CombatFeedback.ShowLaunchTakeoff(transform.position);
launchVisualCoroutine = StartCoroutine(LaunchVisualRoutine(
launchDirection,
launchHeight,
duration));
}
public static bool CanBackHitInterrupt(EnemyState state)
{
return state == EnemyState.Warning || state == EnemyState.Active;
}
private void BeginFollowUpAttack()
{
SetNextAttackPattern();
if (actionGate != null && actionGate.IsAttackLocked)
{
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
RaiseEnemyStaggerClearedIfNeeded();
EnterState(EnemyState.Chase, 0f);
return;
}
if (attackPatternGate != null
&& !attackPatternGate.CanUseAttackPattern(
CurrentAttackPatternIndex))
{
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
RaiseEnemyStaggerClearedIfNeeded();
EnterState(EnemyState.Chase, 0f);
return;
}
attackPatternGate?.NotifyAttackPatternUsed(
CurrentAttackPatternIndex);
Vector2 attackTarget = GetAttackTargetPosition();
Vector2 toPlayer = attackTarget - body.position;
Vector2 direction = GetAttackDirection(toPlayer);
if (UsesHorizontalRootAttackVisual
&& !IsAttackStartAligned(toPlayer))
{
completedAttacksInSequence = AttacksPerSequence;
ReleaseAttackToken();
RaiseEnemyStaggerClearedIfNeeded();
EnterState(EnemyState.Chase, 0f);
return;
}
SetFacing(direction);
float repeatWarning = WarningDuration
* runEventTuning.RepeatWarningDurationMultiplier;
BeginAttackWarning(direction, player.position, repeatWarning);
}
private void BeginAttackWarning(
Vector2 direction,
Vector2 target,
float duration)
{
// Target circles are the only persistent monster telegraph. Keep
// their full warning window even when an event or follow-up
// multiplier would otherwise shorten it below a fair dodge time.
duration = GetEffectiveWarningDuration(duration);
lockedDirection = direction;
capturedTarget = target;
enemyAttack.BeginWarning(
lockedDirection,
capturedTarget,
duration);
attackAnimationStarted = ShouldStartAttackAnimation(
duration,
CurrentAttackPattern.AnimationLeadTime,
false);
if (IsEventEnemy && !groggyTierConfigured)
{
CombatEvents.RaiseEnemyStagger(
gameObject,
0,
Mathf.Max(1, BackHitsToInterrupt),
false);
}
EnterState(EnemyState.Warning, duration);
if (attackAnimationStarted)
{
StartAttackAnimation();
}
}
private float GetEffectiveWarningDuration(float duration)
{
duration = Mathf.Max(0f, duration);
if (CurrentAttackPattern.AttackShape == EnemyAttackShape.TargetCircle)
{
return Mathf.Max(
GameplayConstants.Current.Enemies.MinimumTargetCircleWarningDuration,
duration);
}
return duration;
}
private void SetNextAttackPattern()
{
int count = definition != null ? definition.AttackPatternCount : 0;
if (count <= 0)
{
attackPatternIndex = 0;
return;
}
int sequencePosition = completedAttacksInSequence;
if (sequencePosition >= AttacksPerSequence)
{
sequencePosition = 0;
}
attackPatternIndex = Mathf.Clamp(sequencePosition, 0, count - 1);
}
private void StartAttackAnimation()
{
SetAttacking(true);
if (animator == null)
{
return;
}
string state = CurrentAttackPattern.AnimationState;
if (!string.IsNullOrEmpty(state))
{
animator.Play(state, 0, 0f);
}
}
private float GetEventAdjustedValue(float baseValue, float multiplier)
{
return baseValue * (IsEventEnemy ? multiplier : 1f);
}
private void ConfigureEnemyModel()
{
EnemyModel model = EnsureEnemyModel();
model.ConfigureDefinition(definition);
bool applyEventTuning = IsEventEnemy;
bool applyHealthTuning = runEventRole.HasValue || summoned;
model.ConfigureEventMultipliers(
applyHealthTuning ? runEventTuning.HealthMultiplier : 1f,
applyEventTuning ? runEventTuning.MoveSpeedMultiplier : 1f,
applyEventTuning ? runEventTuning.AttackDamageMultiplier : 1f);
ClampCurrentHealthToMaximum();
}
private EnemyModel EnsureEnemyModel()
{
if (enemyModel == null)
{
enemyModel = GetComponent<EnemyModel>();
if (enemyModel == null)
{
enemyModel = gameObject.AddComponent<EnemyModel>();
}
}
if (enemyModel.Definition != definition)
{
enemyModel.ConfigureDefinition(definition);
}
return enemyModel;
}
private void HandleCharacterStatChanged(CharacterStat stat)
{
if (stat == CharacterStat.MaxHealth)
{
ClampCurrentHealthToMaximum();
}
}
private void ClampCurrentHealthToMaximum()
{
if (CurrentHealth > MaximumHealth)
{
CurrentHealth = MaximumHealth;
}
}
private void UpdateChase()
{
SetNextAttackPattern();
Vector2 attackTarget = GetAttackTargetPosition();
Vector2 toPlayer = attackTarget - body.position;
float distance = toPlayer.magnitude;
Vector2 direction = GetAttackDirection(toPlayer);
SetFacing(direction);
bool rangedAttackOutsideGate = definition.IsRanged
&& !IsRangedAttackStartAllowed;
if (distance <= AttackRange
&& !rangedAttackOutsideGate
&& IsAttackStartAligned(toPlayer)
&& (attackPatternGate == null
|| attackPatternGate.CanUseAttackPattern(
CurrentAttackPatternIndex))
&& AttackTokenManager.Instance != null
&& AttackTokenManager.Instance.TryAcquire(this))
{
ownsAttackToken = true;
attackPatternGate?.NotifyAttackPatternUsed(
CurrentAttackPatternIndex);
completedAttacksInSequence = 0;
remainingBackHitsToInterrupt = BackHitsToInterrupt;
BeginAttackWarning(direction, player.position, WarningDuration);
return;
}
Vector2 movementDirection = direction;
if (UsesHorizontalRootAttackVisual)
{
Vector2 attackSlot = GetHorizontalRootAttackSlot(
attackTarget,
direction);
Vector2 toAttackSlot = attackSlot - body.position;
movementDirection = toAttackSlot.sqrMagnitude > 0.0001f
? toAttackSlot.normalized
: Vector2.zero;
}
if (IsCrowd)
{
Vector2 toApproachPoint =
(Vector2)player.position + crowdApproachOffset - body.position;
movementDirection = toApproachPoint.sqrMagnitude > 0.01f
? toApproachPoint.normalized
: Vector2.zero;
}
else if (definition.IsRanged && rangedAttackOutsideGate)
{
// A ranged attacker must enter the visible, reachable area
// before it can begin an attack. This also prevents its
// preferred-range retreat from pinning it outside the map.
movementDirection = GetRangedApproachDirection(attackTarget);
}
else if (definition.IsRanged && distance < definition.PreferredRange)
{
movementDirection = -direction;
}
Vector2 separation = CalculateSeparation();
Vector2 velocity = Vector2.ClampMagnitude(
movementDirection + separation * separationStrength,
1f) * MoveSpeed;
body.MovePosition(ClampEnemyPosition(
body.position + velocity * Time.fixedDeltaTime));
SetMoving(velocity.sqrMagnitude > 0.0001f);
}
private Vector2 GetHorizontalRootAttackSlot(
Vector2 attackTarget,
Vector2 direction)
{
Vector2 boundedTarget = attackTarget;
ArenaBounds bounds = ArenaBounds.Resolve();
if (bounds != null)
{
Vector2 actorExtents = bounds.SharedActorClampHalfExtents;
boundedTarget.y = Mathf.Clamp(
attackTarget.y,
-actorExtents.y,
actorExtents.y);
}
// Horizontal-root attacks measure range to the player's collider
// center, while the enemy moves by its ground anchor. At the map
// edge the collider center can sit just outside the shared anchor
// band; shorten the horizontal slot so the Euclidean distance can
// still enter AttackRange instead of stalling forever.
float verticalDelta = attackTarget.y - boundedTarget.y;
float range = Mathf.Max(0f, AttackRange);
float horizontalDistance = Mathf.Sqrt(Mathf.Max(
0f,
range * range - verticalDelta * verticalDelta));
horizontalDistance = Mathf.Max(0f, horizontalDistance - 0.02f);
return new Vector2(
attackTarget.x - direction.x * horizontalDistance,
boundedTarget.y);
}
private bool CanStartRangedAttack()
{
ArenaBounds bounds = ArenaBounds.Resolve();
if (bounds == null)
{
return true;
}
Camera camera = Camera.main;
EnemyConstants tuning = GameplayConstants.Current.Enemies;
return bounds.IsInsideReachableArena(body.position, tuning.RangedAttackArenaInset)
&& (camera == null
|| bounds.IsInsideCameraVisible(
camera,
body.position,
tuning.RangedAttackCameraInset));
}
private Vector2 GetRangedApproachDirection(Vector2 targetPosition)
{
ArenaBounds bounds = ArenaBounds.Resolve();
if (bounds == null)
{
return (targetPosition - body.position).sqrMagnitude > 0.0001f
? (targetPosition - body.position).normalized
: Vector2.zero;
}
// Aim slightly beyond the gate's interior edge so a fixed-step
// chase does not stall exactly on the eligibility boundary.
EnemyConstants tuning = GameplayConstants.Current.Enemies;
Vector2 reachable = bounds.GetReachableHalfExtents(
tuning.RangedApproachArenaInset);
Vector2 interiorTarget = new(
Mathf.Clamp(targetPosition.x, -reachable.x, reachable.x),
Mathf.Clamp(targetPosition.y, -reachable.y, reachable.y));
Camera camera = Camera.main;
if (camera != null)
{
Vector2 visible = bounds.GetCameraVisibleHalfExtents(camera);
Vector2 cameraCenter = camera.transform.position;
visible.x = Mathf.Max(0f, visible.x - tuning.RangedApproachCameraInset);
visible.y = Mathf.Max(0f, visible.y - tuning.RangedApproachCameraInset);
// Keep the approach point in the intersection of the
// reachable and visible interiors. This is essential when
// the player is held against an outer map edge.
float minimumX = Mathf.Max(-reachable.x, cameraCenter.x - visible.x);
float maximumX = Mathf.Min(reachable.x, cameraCenter.x + visible.x);
float minimumY = Mathf.Max(-reachable.y, cameraCenter.y - visible.y);
float maximumY = Mathf.Min(reachable.y, cameraCenter.y + visible.y);
if (minimumX <= maximumX)
{
interiorTarget.x = Mathf.Clamp(
interiorTarget.x,
minimumX,
maximumX);
}
if (minimumY <= maximumY)
{
interiorTarget.y = Mathf.Clamp(
interiorTarget.y,
minimumY,
maximumY);
}
}
Vector2 approach = interiorTarget - body.position;
return approach.sqrMagnitude > 0.0001f
? approach.normalized
: Vector2.zero;
}
private Vector2 ClampEnemyPosition(Vector2 position)
{
ArenaBounds bounds = ArenaBounds.Resolve();
return bounds != null
? bounds.ClampEnemyPosition(position, bodyCollider)
: position;
}
private void KeepBodyInsideArena()
{
if (body == null || bodyCollider == null || !bodyCollider.enabled)
{
return;
}
Vector2 clamped = ClampEnemyPosition(body.position);
if ((clamped - body.position).sqrMagnitude <= 0.0000001f)
{
return;
}
body.position = clamped;
enemyAttack?.RebaseActiveTipAfterExternalMotion();
}
private Vector2 GetAttackDirection(Vector2 toPlayer)
{
return EnemyAttack.ResolveAttackDirection(
CurrentAttackPattern.DirectionMode,
toPlayer,
FacingDirection);
}
private Vector2 GetAttackTargetPosition()
{
if (UsesHorizontalRootAttackVisual && playerHealth != null)
{
Collider2D targetCollider =
playerHealth.GetComponent<Collider2D>();
if (targetCollider != null)
{
return targetCollider.bounds.center;
}
}
return player.position;
}
private bool IsAttackStartAligned(Vector2 toPlayer)
{
if (!UsesHorizontalRootAttackVisual)
{
return true;
}
float targetMinY = player.position.y;
float targetMaxY = player.position.y;
if (playerHealth != null)
{
Collider2D targetCollider =
playerHealth.GetComponent<Collider2D>();
if (targetCollider != null)
{
Bounds targetBounds = targetCollider.bounds;
targetMinY = targetBounds.min.y;
targetMaxY = targetBounds.max.y;
}
}
// Match the same world-space horizontal attack band used by
// EnemyAttack's OverlapBox. Collider bounds include the player
// foot offset, so alignment does not infer the body from its
// root transform plus an arbitrary extent.
Vector2 attackOrigin = EnemyAttack.GetAttackOrigin(
transform.position,
GetAttackDirection(toPlayer),
CurrentAttackPattern.AttackOriginOffset
* (CurrentAttackPattern.GeometryScaleMode
== EnemyAttackGeometryScaleMode.RootScale
? EnemyAttack.GetRootScale(transform)
: 1f),
CurrentAttackPattern.DirectionMode);
float attackCenterY = attackOrigin.y;
float attackBand = AttackWidth > 0f
? AttackWidth
: AttackRadius;
float halfAttackBand = attackBand * 0.5f;
return targetMaxY >= attackCenterY - halfAttackBand
&& targetMinY <= attackCenterY + halfAttackBand;
}
private void UpdateActiveMovement()
{
if (AttackShape == EnemyAttackShape.Body)
{
body.MovePosition(ClampEnemyPosition(
body.position + lockedDirection
* (GameplayConstants.Current.Enemies.BodyContactSpeed
* Time.fixedDeltaTime)));
}
}
private Vector2 CalculateSeparation()
{
if (Time.fixedTime < nextSeparationRefreshTime)
{
return cachedSeparation;
}
nextSeparationRefreshTime =
Time.fixedTime + GameplayConstants.Current.Enemies.SeparationRefreshInterval;
int count = Physics2D.OverlapCircle(
body.position,
separationRadius,
ContactFilter2D.noFilter,
SeparationBuffer);
Vector2 separation = Vector2.zero;
for (int i = 0; i < count; i++)
{
EnemyController other = SeparationBuffer[i].GetComponentInParent<EnemyController>();
if (other == null || other == this || other.IsDead)
{
continue;
}
Vector2 away = body.position - (Vector2)other.transform.position;
if (away.sqrMagnitude > 0.0001f)
{
separation += away.normalized;
}
}
cachedSeparation = separation;
return cachedSeparation;
}
private void SetFacing(Vector2 direction)
{
if (direction.sqrMagnitude <= 0f)
{
return;
}
FacingDirection = direction.normalized;
if (!Mathf.Approximately(direction.x, 0f))
{
spriteRenderer.flipX = direction.x < 0f;
}
}
private void EnterState(EnemyState state, float duration)
{
State = state;
stateTimer = duration;
StateDuration = Mathf.Max(0f, duration);
if (body != null
&& definition != null
&& !AllowsStateMovement(state, AttackShape))
{
body.linearVelocity = Vector2.zero;
}
SetMoving(state == EnemyState.Chase);
SetAttacking(state == EnemyState.Active
|| (state == EnemyState.Warning && attackAnimationStarted));
}
public static bool ShouldStartAttackAnimation(
float warningTimeRemaining,
float leadTime,
bool animationStarted)
{
if (animationStarted)
{
return false;
}
return leadTime <= 0f
|| warningTimeRemaining <= Mathf.Max(0f, leadTime);
}
public static bool AllowsStateMovement(EnemyState state, EnemyAttackShape attackShape)
{
return state == EnemyState.Chase
|| (state == EnemyState.Active && attackShape == EnemyAttackShape.Body);
}
public static bool AllowsContactDamage(
EnemyState state,
EnemyAttackShape attackShape)
{
return state == EnemyState.Active && attackShape == EnemyAttackShape.Body;
}
public static float GetPlayerKnockbackDistance(EnemyKind kind)
{
EnemyConstants tuning = GameplayConstants.Current.Enemies;
return kind == EnemyKind.Lancer
|| kind == EnemyKind.GreatswordSkeleton
|| kind == EnemyKind.NecroGolem
? tuning.HeavyPlayerKnockbackDistance
: tuning.NormalPlayerKnockbackDistance;
}
private void SetMoving(bool moving)
{
if (animator != null)
{
animator.SetBool(IsMovingParameter, moving);
}
}
private void SetAttacking(bool attacking)
{
if (animator != null)
{
animator.SetBool(IsAttackingParameter, attacking);
}
}
private IEnumerator LaunchVisualRoutine(
Vector2 launchDirection,
float launchHeight,
float duration)
{
airborneVisualObject = new GameObject("Airborne Enemy Visual");
airborneVisualObject.transform.SetParent(transform, false);
SpriteRenderer airborneRenderer =
airborneVisualObject.AddComponent<SpriteRenderer>();
airborneRenderer.sprite = spriteRenderer.sprite;
airborneRenderer.color = spriteRenderer.color;
airborneRenderer.flipX = spriteRenderer.flipX;
airborneRenderer.flipY = spriteRenderer.flipY;
airborneRenderer.sharedMaterial = spriteRenderer.sharedMaterial;
airborneRenderer.sortingLayerID = spriteRenderer.sortingLayerID;
airborneRenderer.sortingOrder = spriteRenderer.sortingOrder + 2;
launchShadowObject = new GameObject("Launch Shadow");
launchShadowObject.transform.SetParent(transform, false);
launchShadowObject.transform.localPosition = bodyCollider != null
? bodyCollider.offset
: new Vector2(0f, -0.28f);
SpriteRenderer shadowRenderer =
launchShadowObject.AddComponent<SpriteRenderer>();
shadowRenderer.sprite = GetLaunchWhiteSprite();
shadowRenderer.color = new Color(0f, 0f, 0f, 0.28f);
shadowRenderer.sortingLayerID = spriteRenderer.sortingLayerID;
shadowRenderer.sortingOrder = spriteRenderer.sortingOrder - 1;
AcquireSpriteVisibilityLease("Launch");
launchSpriteLease = true;
float rotationDirection = launchDirection.x >= 0f ? -1f : 1f;
float elapsed = 0f;
while (elapsed < duration
&& airborneVisualObject != null
&& launchShadowObject != null)
{
elapsed += Time.deltaTime;
float progress = Mathf.Clamp01(elapsed / duration);
float arc = 4f * progress * (1f - progress);
float steppedAngle =
Mathf.Round(rotationDirection * 20f * arc / 5f) * 5f;
airborneVisualObject.transform.localPosition =
Vector3.up * (launchHeight * arc);
airborneVisualObject.transform.localRotation =
Quaternion.Euler(0f, 0f, steppedAngle);
airborneVisualObject.transform.localScale = new Vector3(
1f + arc * 0.05f,
1f + arc * 0.08f,
1f);
airborneRenderer.sprite = spriteRenderer.sprite;
airborneRenderer.color = spriteRenderer.color;
airborneRenderer.flipX = spriteRenderer.flipX;
airborneRenderer.sortingOrder = spriteRenderer.sortingOrder + 2;
launchShadowObject.transform.localScale = new Vector3(
Mathf.Lerp(0.42f, 0.22f, arc),
Mathf.Lerp(0.14f, 0.07f, arc),
1f);
Color shadowColor = shadowRenderer.color;
shadowColor.a = Mathf.Lerp(0.28f, 0.1f, arc);
shadowRenderer.color = shadowColor;
shadowRenderer.sortingOrder = spriteRenderer.sortingOrder - 1;
yield return null;
}
launchVisualCoroutine = null;
CombatFeedback.ShowLaunchLanding(transform.position);
CleanupLaunchVisual();
}
private void StopLaunchVisual()
{
if (launchVisualCoroutine != null)
{
StopCoroutine(launchVisualCoroutine);
launchVisualCoroutine = null;
}
CleanupLaunchVisual();
}
private void CleanupLaunchVisual()
{
if (spriteRenderer != null)
{
if (launchSpriteLease)
{
ReleaseSpriteVisibilityLease("Launch");
launchSpriteLease = false;
}
}
if (airborneVisualObject != null)
{
Destroy(airborneVisualObject);
airborneVisualObject = null;
}
if (launchShadowObject != null)
{
Destroy(launchShadowObject);
launchShadowObject = null;
}
}
public void AcquireSpriteVisualLease(string reason)
{
AcquireSpriteVisibilityLease(reason);
}
public void ReleaseSpriteVisualLease(string reason)
{
ReleaseSpriteVisibilityLease(reason);
}
private void AcquireSpriteVisibilityLease(string reason)
{
if (string.IsNullOrEmpty(reason))
{
reason = "Unknown";
}
spriteVisibilityLeaseReasons.TryGetValue(reason, out int count);
spriteVisibilityLeaseReasons[reason] = count + 1;
if (spriteRenderer != null)
{
spriteRenderer.enabled = false;
}
}
private void ReleaseSpriteVisibilityLease(string reason)
{
if (string.IsNullOrEmpty(reason)
|| !spriteVisibilityLeaseReasons.TryGetValue(reason, out int count))
{
return;
}
if (count <= 1)
{
spriteVisibilityLeaseReasons.Remove(reason);
}
else
{
spriteVisibilityLeaseReasons[reason] = count - 1;
}
if (spriteRenderer != null && spriteVisibilityLeaseReasons.Count == 0)
{
spriteRenderer.enabled = true;
}
}
private static Sprite GetLaunchWhiteSprite()
{
if (launchWhiteSprite == null)
{
launchWhiteSprite = Sprite.Create(
Texture2D.whiteTexture,
new Rect(
0f,
0f,
Texture2D.whiteTexture.width,
Texture2D.whiteTexture.height),
new Vector2(0.5f, 0.5f),
Texture2D.whiteTexture.width);
launchWhiteSprite.name = "Launch Shadow White Sprite";
launchWhiteSprite.hideFlags = HideFlags.HideAndDontSave;
}
return launchWhiteSprite;
}
private void ReleaseAttackToken()
{
if (!ownsAttackToken)
{
return;
}
ownsAttackToken = false;
AttackTokenManager.Instance?.Release(this);
}
private void RaiseEnemyStaggerClearedIfNeeded()
{
if (!groggyTierConfigured)
{
CombatEvents.RaiseEnemyStaggerCleared(gameObject);
}
}
private void Die()
{
ClearTimedEffects();
EnterState(EnemyState.Dead, 0f);
ReleaseAttackToken();
enemyAttack.EndAttack();
bodyCollider.enabled = false;
SetMoving(false);
float deathDuration = GetDeathAnimationDuration();
if (animator != null && animator.HasState(0, DeathState))
{
animator.Play(DeathState, 0, 0f);
}
if (experienceOrbPrefab != null)
{
ExperienceOrb orb = Instantiate(experienceOrbPrefab, transform.position, Quaternion.identity);
orb.Initialize(
experienceValueOverride >= 0
? experienceValueOverride
: definition.ExperienceValue);
}
CombatEvents.RaiseEnemyDied(gameObject);
OnDied?.Invoke(this);
Destroy(
gameObject,
deathDuration + GameplayConstants.Current.Enemies.DeathRemovalPadding);
}
private float GetDeathAnimationDuration()
{
if (animator == null || animator.runtimeAnimatorController == null)
{
return GameplayConstants.Current.Enemies.DefaultDeathDuration;
}
foreach (AnimationClip clip in animator.runtimeAnimatorController.animationClips)
{
if (clip != null
&& (clip.name.EndsWith(
"_Death",
StringComparison.OrdinalIgnoreCase)
|| clip.name.Contains(
"_Death-stage-v1",
StringComparison.OrdinalIgnoreCase)))
{
return clip.length;
}
}
return GameplayConstants.Current.Enemies.DefaultDeathDuration;
}
}
}