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 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 recentArtifactContactKeys = new(StringComparer.Ordinal); private readonly Queue 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 OnDied; public event Action 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(); // 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(); spriteRenderer = GetComponent(); animator = GetComponent(); enemyAttack = GetComponent(); ConfigureEnemyModel(); MonoBehaviour[] behaviours = GetComponents(); 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(); if (statusVisual == null) { statusVisual = gameObject.AddComponent(); } if (definition != null && definition.AttackPatternCount > 0 && GetComponent() == null) { gameObject.AddComponent(); } 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(); if (playerController == null) { enabled = false; return; } player = playerController.transform; playerHealth = playerController.GetComponent(); activeArtifactController = playerController.GetComponent(); 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(); 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(); } else { activeArtifactController = FindAnyObjectByType(); } } 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 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() : FindAnyObjectByType(); } 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(); if (enemyModel == null) { enemyModel = gameObject.AddComponent(); } } 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(); 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(); 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(); 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(); 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(); 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; } } }