2426 lines
85 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|