using System.Collections.Generic; using BumpCombat.Constants; using BumpCombat.Player; using UnityEngine; namespace BumpCombat.Enemies { [RequireComponent(typeof(EnemyController))] public sealed class EnemyAttack : MonoBehaviour { private EnemyController controller; private PlayerHealth playerHealth; private AttackWarningVisual warningVisual; private LancerThrustVisual lancerVisual; private Vector2 lockedDirection; private Vector2 targetPosition; private Vector2 attackOrigin; private Vector2 lancerPreviousTip; private float lancerPreviousProgress; private bool lancerAttackActive; private bool lancerHitConfirmed; private bool lancerTipNeedsRebase; private bool necrofireRayActive; private bool necrofireRayHitConfirmed; private bool meleeAttackActive; private bool meleeHitConfirmed; private StageEnemyEffectVisual necrofireRayVisual; private readonly List stageAoeVisuals = new(); private readonly List stageMultiWarnings = new(); public bool IsLancerAttack => controller != null && controller.Definition != null && controller.Definition.Kind == EnemyKind.Lancer; public Vector2 LockedDirection => lockedDirection; public Vector2 LockedAttackOrigin => GetAttackOrigin( GetAttackRootPosition(), lockedDirection, controller != null ? GetAuthoredOriginOffset() : Vector2.zero, controller != null ? controller.CurrentAttackPattern.DirectionMode : EnemyAttackDirectionMode.LockedDirection); private Vector2 GetAttackRootPosition() { return controller != null && controller.CurrentAttackPattern.DirectionMode == EnemyAttackDirectionMode.HorizontalRoot ? controller.GroundAnchorPosition : attackOrigin; } public static float GetRootScale(Transform root) { if (root == null) { return 1f; } Vector3 scale = root.lossyScale; return Mathf.Max( 0.0001f, Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.y))); } private float GeometryScale => controller != null && controller.CurrentAttackPattern.GeometryScaleMode == EnemyAttackGeometryScaleMode.RootScale ? GetRootScale(transform) : 1f; private Vector2 GetAuthoredOriginOffset() { return controller.CurrentAttackPattern.AttackOriginOffset * GeometryScale; } private float GetAttackLength() { return controller.CurrentAttackPattern.GeometryScaleMode == EnemyAttackGeometryScaleMode.RootScale ? controller.CurrentAttackPattern.AttackLength * GeometryScale : controller.AttackLength; } private float GetAttackWidth() { return controller.CurrentAttackPattern.GeometryScaleMode == EnemyAttackGeometryScaleMode.RootScale ? controller.CurrentAttackPattern.AttackWidth * GeometryScale : controller.AttackWidth; } private float GetAttackRadius() { return controller.CurrentAttackPattern.GeometryScaleMode == EnemyAttackGeometryScaleMode.RootScale ? controller.CurrentAttackPattern.AttackRadius * GeometryScale : controller.AttackRadius; } public static Vector2 ResolveAttackDirection( EnemyAttackDirectionMode directionMode, Vector2 requestedDirection, Vector2 fallbackDirection) { Vector2 fallback = fallbackDirection.sqrMagnitude > 0.0001f ? fallbackDirection.normalized : Vector2.right; if (directionMode == EnemyAttackDirectionMode.HorizontalRoot) { float x = Mathf.Abs(requestedDirection.x) > 0.0001f ? requestedDirection.x : fallback.x; return Vector2.right * (x < 0f ? -1f : 1f); } return requestedDirection.sqrMagnitude > 0.0001f ? requestedDirection.normalized : fallback; } public static Vector2 GetAttackOrigin( Vector2 rootPosition, Vector2 direction, Vector2 localOffset, EnemyAttackDirectionMode directionMode = EnemyAttackDirectionMode.LockedDirection) { Vector2 resolvedDirection = direction.sqrMagnitude > 0.0001f ? direction.normalized : Vector2.right; Vector2 side = directionMode == EnemyAttackDirectionMode.HorizontalRoot ? Vector2.up : new Vector2(-resolvedDirection.y, resolvedDirection.x); return rootPosition + resolvedDirection * localOffset.x + side * localOffset.y; } public bool IsContactWindowActive(float activeElapsed) { return controller != null && controller.CurrentAttackPattern.IsContactWindowActive( activeElapsed, controller.ActiveDuration); } private void Awake() { controller = GetComponent(); if (IsLancerAttack) { lancerVisual = GetComponent(); if (lancerVisual == null) { lancerVisual = gameObject.AddComponent(); } return; } GameObject warningObject = new("Attack Warning"); warningObject.transform.SetParent(transform, false); warningVisual = warningObject.AddComponent(); } private void Start() { playerHealth = FindAnyObjectByType(); } private void OnDestroy() { if (warningVisual != null) { Destroy(warningVisual.gameObject); } } private void OnDisable() { EndAttack(IsLancerAttack); } public void BeginWarning(Vector2 direction, Vector2 capturedTarget) { BeginWarning(direction, capturedTarget, controller != null ? controller.WarningDuration : 0f); } public void BeginWarning( Vector2 direction, Vector2 capturedTarget, float warningDuration) { lockedDirection = ResolveAttackDirection( controller.CurrentAttackPattern.DirectionMode, direction, controller.FacingDirection); targetPosition = capturedTarget; attackOrigin = transform.position; meleeAttackActive = false; meleeHitConfirmed = false; if (IsLancerAttack) { EnsureLancerVisual(); lancerVisual.Begin(this, controller); warningVisual?.Hide(); return; } warningVisual.Show( controller, transform.position, lockedDirection, targetPosition); if (controller.Definition.Kind == EnemyKind.Necromancer && controller.CurrentAttackPatternIndex == 2) { warningVisual.ShowTargetCircle( targetPosition, controller.AttackRadius * 0.72f); } if (controller.Definition.Kind == EnemyKind.Necromancer && controller.AttackShape == EnemyAttackShape.TargetCircle) { CreateNecromancerAoeVisuals(warningDuration); } } public void Activate(float activeElapsed = 0f) { if (IsLancerAttack) { BeginLancerActive(); return; } warningVisual.SetActivePhase(true); if (controller.Definition.Kind == EnemyKind.Necromancer && controller.CurrentAttackPatternIndex == 2) { ActivateNecromancerMultiAoe(); return; } if (IsNecrofireRayAttack()) { BeginNecrofireRay(); return; } if (IsStageProjectileAttack()) { LaunchStageProjectile(); return; } if (controller.AttackShape == EnemyAttackShape.Body || playerHealth == null) { return; } if (controller.AttackShape == EnemyAttackShape.TargetCircle) { Vector2 playerPosition = playerHealth.transform.position; Collider2D playerCollider = GetPlayerCollider(); if (playerCollider != null) { playerPosition = playerCollider.bounds.center; } if (!ContainsPoint(playerPosition)) { return; } Vector2 knockback = playerPosition - targetPosition; if (knockback.sqrMagnitude <= 0.0001f) { knockback = lockedDirection; } playerHealth.TryTakeDamage( controller.AttackDamage, knockback.normalized, controller.PlayerKnockbackDistance); return; } if (!IsPersistentMeleeAttack()) { return; } meleeAttackActive = true; TryApplyMeleeHit(activeElapsed); } private void CreateNecromancerAoeVisuals(float warningDuration) { ClearStageAoeVisuals(); if (controller.CurrentAttackPatternIndex != 2) { stageAoeVisuals.Add(StageEnemyEffectVisual.PlayAoe( gameObject, targetPosition, controller.AttackRadius, warningDuration + controller.ActiveDuration)); return; } Vector2[] points = GetNecromancerMultiAoePoints(); float radius = controller.AttackRadius * 0.72f; for (int i = 0; i < points.Length; i++) { stageAoeVisuals.Add(StageEnemyEffectVisual.PlayAoe( gameObject, points[i], radius, warningDuration + controller.ActiveDuration)); if (i > 0) { GameObject warningObject = new("Necromancer AOE Warning"); stageMultiWarnings.Add(warningObject.AddComponent()); stageMultiWarnings[^1].ShowTargetCircle(points[i], radius); } } } private void ActivateNecromancerMultiAoe() { for (int i = 0; i < stageMultiWarnings.Count; i++) { stageMultiWarnings[i]?.SetActivePhase(true); } if (playerHealth == null) { playerHealth = FindAnyObjectByType(); } if (playerHealth == null) { return; } Vector2[] points = GetNecromancerMultiAoePoints(); float radius = controller.AttackRadius * 0.72f; for (int i = 0; i < points.Length; i++) { if (ContainsCircleTarget(points[i], radius)) { Vector2 knockback = ((Vector2)playerHealth.transform.position - points[i]).normalized; playerHealth.TryTakeDamage( controller.AttackDamage, knockback, controller.PlayerKnockbackDistance); break; } } } private Vector2[] GetNecromancerMultiAoePoints() { Vector2 side = new Vector2(-lockedDirection.y, lockedDirection.x); return new[] { targetPosition, targetPosition + side * controller.AttackRadius * 1.2f, targetPosition - side * controller.AttackRadius * 1.2f, }; } private bool IsStageProjectileAttack() { if (controller == null || controller.Definition == null) { return false; } return controller.Definition.Kind == EnemyKind.SkeletonArcher; } private bool IsNecrofireRayAttack() { return controller != null && controller.Definition != null && controller.Definition.Kind == EnemyKind.Necrofire; } private void BeginNecrofireRay() { necrofireRayActive = true; necrofireRayHitConfirmed = false; Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection : Vector2.right; float length = controller.AttackLength; necrofireRayVisual = StageEnemyEffectVisual.PlayRay( gameObject, attackOrigin, direction, length, controller.AttackWidth, controller.ActiveDuration); TickNecrofireRay(); } private void LaunchStageProjectile() { if (playerHealth == null) { playerHealth = FindAnyObjectByType(); } if (playerHealth == null) { return; } Vector2 direction = lockedDirection; if (direction.sqrMagnitude <= 0.0001f) { direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection : Vector2.right; } EnemyProjectile.Launch( controller, transform.position, direction, controller.AttackDamage, controller.Definition.ProjectileSpeed, Mathf.Max(controller.AttackLength, controller.AttackRange * 1.5f), controller.Definition.ProjectileRadius, false); } public void TickActive(float activeElapsed) { if (necrofireRayActive) { TickNecrofireRay(); } if (meleeAttackActive && !meleeHitConfirmed) { // A player can enter a short melee active window after the // first sample. Keep the check shape-limited and latch only // after TryTakeDamage succeeds, so invulnerability does not // consume the attack window. TryApplyMeleeHit(activeElapsed); } if (!lancerAttackActive || lancerHitConfirmed || !IsLancerAttack) { return; } float activeDuration = controller.ActiveDuration; float normalizedActive = activeDuration <= 0.0001f ? 1f : Mathf.Clamp01(activeElapsed / activeDuration); float collisionProgress = Mathf.Min( normalizedActive, LancerAttackMotion.ActiveExtensionEndFraction); if (lancerTipNeedsRebase) { lancerPreviousTip = GetLancerTip(lancerPreviousProgress); lancerTipNeedsRebase = false; } if (collisionProgress <= lancerPreviousProgress + 0.0001f) { // Keep checking the tip after a blocked attempt. Player // invulnerability may expire before the active window ends. if (LancerAttackMotion.IsDamageWindow(normalizedActive)) { SweepLancerTip(lancerPreviousTip, lancerPreviousTip); } return; } Vector2 currentTip = GetLancerTip(collisionProgress); SweepLancerTip(lancerPreviousTip, currentTip); lancerPreviousTip = currentTip; lancerPreviousProgress = collisionProgress; } public void EndAttack(bool cancelled = false) { necrofireRayActive = false; necrofireRayHitConfirmed = false; meleeAttackActive = false; meleeHitConfirmed = false; if (necrofireRayVisual != null) { Destroy(necrofireRayVisual.gameObject); necrofireRayVisual = null; } lancerAttackActive = false; lancerHitConfirmed = false; lancerPreviousProgress = 0f; lancerPreviousTip = Vector2.zero; lancerTipNeedsRebase = false; if (lancerVisual != null) { lancerVisual.Stop(cancelled); } ClearStageAoeVisuals(); for (int i = 0; i < stageMultiWarnings.Count; i++) { if (stageMultiWarnings[i] != null) { Destroy(stageMultiWarnings[i].gameObject); } } stageMultiWarnings.Clear(); if (warningVisual == null) { return; } if (cancelled) { warningVisual.CollapseAndHide(); } else { warningVisual.Hide(); } } private void ClearStageAoeVisuals() { for (int i = 0; i < stageAoeVisuals.Count; i++) { if (stageAoeVisuals[i] != null) { Destroy(stageAoeVisuals[i].gameObject); } } stageAoeVisuals.Clear(); } private void EnsureLancerVisual() { if (lancerVisual != null) { return; } lancerVisual = GetComponent(); if (lancerVisual == null) { lancerVisual = gameObject.AddComponent(); } } private void BeginLancerActive() { lancerAttackActive = true; lancerHitConfirmed = false; lancerPreviousProgress = 0f; lancerPreviousTip = GetLancerTip(0f); lancerTipNeedsRebase = false; SweepLancerTip(lancerPreviousTip, lancerPreviousTip); } public void RebaseActiveTipAfterExternalMotion() { if (lancerAttackActive) { lancerTipNeedsRebase = true; } } private Vector2 GetLancerTip(float normalizedReach) { float worldScale = Mathf.Abs(transform.lossyScale.x); if (worldScale <= 0.0001f) { worldScale = 1f; } return LancerAttackMotion.GetTipPosition( transform.position, lockedDirection, controller.AttackLength, LancerAttackMotion.EvaluateActiveReachFraction( normalizedReach), worldScale); } private void SweepLancerTip(Vector2 previousTip, Vector2 currentTip) { if (playerHealth == null) { playerHealth = FindAnyObjectByType(); } if (playerHealth == null) { return; } float radius = Mathf.Max(0.001f, controller.AttackWidth * 0.5f); Vector2 delta = currentTip - previousTip; if (delta.sqrMagnitude <= 0.000001f) { Collider2D[] overlapHits = Physics2D.OverlapCircleAll( currentTip, radius); for (int i = 0; i < overlapHits.Length; i++) { if (TryDamageLancerTarget(overlapHits[i])) { return; } } return; } RaycastHit2D[] castHits = Physics2D.CircleCastAll( previousTip, radius, delta.normalized, delta.magnitude); for (int i = 0; i < castHits.Length; i++) { if (TryDamageLancerTarget(castHits[i].collider)) { return; } } } private bool TryDamageLancerTarget(Collider2D collider) { if (collider == null) { return false; } PlayerHealth health = collider.GetComponentInParent(); if (health == null || health != playerHealth) { return false; } Vector2 knockback = ((Vector2)health.transform.position - (Vector2)transform.position); if (knockback.sqrMagnitude <= 0.0001f) { knockback = lockedDirection; } if (!health.TryTakeDamage( controller.AttackDamage, knockback.normalized, controller.PlayerKnockbackDistance)) { return false; } lancerHitConfirmed = true; return true; } private bool ContainsPoint(Vector2 playerPosition) { Vector2 origin = GetAttackOrigin( GetAttackRootPosition(), lockedDirection, GetAuthoredOriginOffset(), controller.CurrentAttackPattern.DirectionMode); Vector2 delta = playerPosition - origin; switch (controller.AttackShape) { case EnemyAttackShape.Box: return ContainsBoxTarget(origin, delta); case EnemyAttackShape.Cone: return ContainsConeTarget(origin); case EnemyAttackShape.TargetCircle: return ContainsCircleTarget(targetPosition, controller.AttackRadius); default: return false; } } private bool ContainsBoxTarget(Vector2 origin, Vector2 rootDelta) { Collider2D playerCollider = GetPlayerCollider(); if (playerCollider == null) { float forward = Vector2.Dot(rootDelta, lockedDirection); float lateral = Mathf.Abs(Vector2.Dot( rootDelta, new Vector2(-lockedDirection.y, lockedDirection.x))); return forward >= 0f && forward <= GetAttackLength() && lateral <= GetAttackWidth() * 0.5f; } Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection.normalized : Vector2.right; Vector2 colliderCenterDelta = (Vector2)playerCollider.bounds.center - origin; if (Vector2.Dot(colliderCenterDelta, direction) < 0f) { return false; } float attackLength = GetAttackLength(); float attackWidth = GetAttackWidth(); Vector2 boxCenter = origin + direction * (attackLength * 0.5f); float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; Collider2D[] hits = Physics2D.OverlapBoxAll( boxCenter, new Vector2(attackLength, attackWidth), angle); return ContainsPlayerCollider(hits); } private bool ContainsConeTarget(Vector2 origin) { Collider2D playerCollider = GetPlayerCollider(); if (playerCollider == null) { Vector2 delta = (Vector2)playerHealth.transform.position - origin; return delta.magnitude <= GetAttackRadius() && Vector2.Dot(delta.normalized, lockedDirection) >= 0.5f; } Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection.normalized : Vector2.right; Vector2 centerDelta = (Vector2)playerCollider.bounds.center - origin; if (Vector2.Dot(centerDelta, direction) < 0f) { return false; } if (controller.CurrentAttackPattern.DirectionMode == EnemyAttackDirectionMode.HorizontalRoot && GetAttackWidth() > 0f && !ColliderIntersectsHorizontalBand( playerCollider, origin, GetAttackWidth())) { return false; } if (!ColliderIntersectsCone( playerCollider, origin, direction, GetAttackRadius())) { return false; } return true; } private static bool ColliderIntersectsHorizontalBand( Collider2D collider, Vector2 origin, float bandHeight) { Bounds bounds = collider.bounds; float halfHeight = bandHeight * 0.5f; return bounds.max.y >= origin.y - halfHeight && bounds.min.y <= origin.y + halfHeight; } private bool IsPersistentMeleeAttack() { if (controller == null || IsLancerAttack || IsNecrofireRayAttack() || IsStageProjectileAttack()) { return false; } return controller.AttackShape == EnemyAttackShape.Box || controller.AttackShape == EnemyAttackShape.Cone; } private void TryApplyMeleeHit(float activeElapsed) { if (!meleeAttackActive || meleeHitConfirmed) { return; } if (!controller.CurrentAttackPattern.IsContactWindowActive( activeElapsed, controller.ActiveDuration)) { return; } if (playerHealth == null) { playerHealth = FindAnyObjectByType(); } if (playerHealth == null) { return; } Collider2D playerCollider = GetPlayerCollider(); Vector2 playerPosition = playerCollider != null ? (Vector2)playerCollider.bounds.center : playerHealth.transform.position; if (!ContainsPoint(playerPosition)) { return; } Vector2 knockback = playerPosition - (Vector2)transform.position; if (knockback.sqrMagnitude <= 0.0001f) { knockback = lockedDirection; } if (playerHealth.TryTakeDamage( controller.AttackDamage, knockback.normalized, controller.PlayerKnockbackDistance)) { meleeHitConfirmed = true; } } private bool ColliderIntersectsCone( Collider2D collider, Vector2 origin, Vector2 direction, float radius) { CircleCollider2D circle = collider as CircleCollider2D; if (circle == null) { Vector2 closestPoint = collider.ClosestPoint(origin); Vector2 deltaToClosest = closestPoint - origin; return deltaToClosest.sqrMagnitude <= 0.0001f || Vector2.Dot( deltaToClosest.normalized, direction) >= 0.5f; } float worldRadius = circle.radius * Mathf.Max( Mathf.Abs(circle.transform.lossyScale.x), Mathf.Abs(circle.transform.lossyScale.y)); return CircleIntersectsCone( (Vector2)circle.bounds.center, worldRadius, origin, direction, radius); } private static bool CircleIntersectsCone( Vector2 circleCenter, float circleRadius, Vector2 origin, Vector2 direction, float radius) { Vector2 delta = circleCenter - origin; float distance = delta.magnitude; if (distance <= circleRadius) { return true; } float coneDirectionThreshold = GameplayConstants.Current.Enemies.ConeDirectionThreshold; if (distance <= radius + circleRadius && Vector2.Dot(delta / distance, direction) >= coneDirectionThreshold) { return true; } float sideComponent = Mathf.Sqrt( 1f - coneDirectionThreshold * coneDirectionThreshold); Vector2 side = new Vector2(-direction.y, direction.x); Vector2 edgeDirection = direction * coneDirectionThreshold + side * sideComponent; if (CircleIntersectsSegment( circleCenter, origin, origin + edgeDirection * radius, circleRadius)) { return true; } edgeDirection = direction * coneDirectionThreshold - side * sideComponent; return CircleIntersectsSegment( circleCenter, origin, origin + edgeDirection * radius, circleRadius); } private static bool CircleIntersectsSegment( Vector2 circleCenter, Vector2 start, Vector2 end, float circleRadius) { Vector2 segment = end - start; float segmentLengthSquared = segment.sqrMagnitude; float t = segmentLengthSquared <= 0.000001f ? 0f : Mathf.Clamp01(Vector2.Dot( circleCenter - start, segment) / segmentLengthSquared); Vector2 closest = start + segment * t; return (circleCenter - closest).sqrMagnitude <= circleRadius * circleRadius + 0.0001f; } private bool ContainsCircleTarget(Vector2 center, float radius) { Collider2D[] hits = Physics2D.OverlapCircleAll(center, radius); return ContainsPlayerCollider(hits); } private bool ContainsPlayerCollider(Collider2D[] hits) { Collider2D playerCollider = GetPlayerCollider(); if (playerCollider == null || hits == null) { return false; } for (int i = 0; i < hits.Length; i++) { if (hits[i] == playerCollider || hits[i].GetComponentInParent() == playerHealth) { return true; } } return false; } private Collider2D GetPlayerCollider() { return playerHealth != null ? playerHealth.GetComponent() : null; } private void TickNecrofireRay() { if (!necrofireRayActive || necrofireRayHitConfirmed) { return; } if (playerHealth == null) { playerHealth = FindAnyObjectByType(); } if (playerHealth == null) { return; } Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection : Vector2.right; float length = controller.AttackLength; float width = Mathf.Max(0.001f, controller.AttackWidth); Vector2 center = attackOrigin + direction * (length * 0.5f); float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; Collider2D[] overlapHits = Physics2D.OverlapBoxAll( center, new Vector2(length, width), angle); for (int i = 0; i < overlapHits.Length; i++) { Collider2D collider = overlapHits[i]; if (collider == null) { continue; } PlayerHealth health = collider.GetComponentInParent(); if (health == null || health != playerHealth) { continue; } Vector2 delta = (Vector2)health.transform.position - attackOrigin; Vector2 knockback = delta.sqrMagnitude > 0.0001f ? delta.normalized : direction; if (health.TryTakeDamage( controller.AttackDamage, knockback, controller.PlayerKnockbackDistance)) { necrofireRayHitConfirmed = true; return; } } } } }