using System.Collections.Generic; using BumpCombat.Core; using BumpCombat.Constants; using BumpCombat.Enemies; using BumpCombat.Player; using UnityEngine; namespace BumpCombat.Combat { [RequireComponent(typeof(PlayerController))] [RequireComponent(typeof(PlayerStats))] public sealed class BumpCombatResolver : MonoBehaviour { private float baseDamage => GameplayConstants.Current.Combat.BaseDamage; private float dashDamage => GameplayConstants.Current.Combat.DashDamage; private float strongDashDamage => GameplayConstants.Current.Combat.StrongDashDamage; private float minimumAttackSpeed => GameplayConstants.Current.Combat.MinimumAttackSpeed; private float minimumApproachDot => GameplayConstants.Current.Combat.MinimumApproachDot; private float knockbackForce => GameplayConstants.Current.Combat.KnockbackForce; private float dashKnockbackForce => GameplayConstants.Current.Combat.DashKnockbackForce; private float strongDashKnockbackForce => GameplayConstants.Current.Combat.StrongDashKnockbackForce; private float strongDashLaunchHeight => GameplayConstants.Current.Combat.StrongDashLaunchHeight; private float strongDashLaunchDuration => GameplayConstants.Current.Combat.StrongDashLaunchDuration; private float playerImpactRecoilDistance => GameplayConstants.Current.Combat.PlayerImpactRecoilDistance; private float frontKnockbackMultiplier => GameplayConstants.Current.Combat.FrontKnockbackMultiplier; private float frontPlayerImpactRecoilDistance => GameplayConstants.Current.Combat.FrontPlayerImpactRecoilDistance; private float eventBackPlayerImpactRecoilDistance => GameplayConstants.Current.Combat.EventBackPlayerImpactRecoilDistance; private float shieldedPlayerImpactRecoilDistance => GameplayConstants.Current.Combat.ShieldedPlayerImpactRecoilDistance; private float minimumOffsetDistance => GameplayConstants.Current.Combat.MinimumOffsetDistance; private float normalHitInterval => GameplayConstants.Current.Combat.NormalHitInterval; private float dashContactSkin => GameplayConstants.Current.Combat.DashContactSkin; private readonly HashSet dashTargets = new(); private readonly RaycastHit2D[] dashHits = new RaycastHit2D[64]; private readonly Dictionary nextNormalHitTimes = new(); private PlayerStats playerStats; private PlayerController playerController; private DashController dashController; private ActiveArtifactController activeArtifactController; private CircleCollider2D bodyCollider; private void Awake() { playerStats = GetComponent(); playerController = GetComponent(); dashController = GetComponent(); activeArtifactController = GetComponent(); bodyCollider = GetComponent(); } public void BeginDashPathContactSession() { dashTargets.Clear(); } private void OnCollisionEnter2D(Collision2D collision) { TryResolveNormalCollision(collision); } private void OnCollisionStay2D(Collision2D collision) { TryResolveNormalCollision(collision); } private void OnTriggerEnter2D(Collider2D other) { TryResolveCrowdTrigger(other); } private void OnTriggerStay2D(Collider2D other) { TryResolveCrowdTrigger(other); } private void TryResolveNormalCollision( Collision2D collision) { EnemyController enemy = collision.collider.GetComponentInParent(); if (enemy == null || enemy.IsCrowd) { return; } Vector2 hitPosition = collision.contactCount > 0 ? collision.GetContact(0).point : Vector2.Lerp(transform.position, enemy.transform.position, 0.5f); TryResolveNormalContact(enemy, hitPosition); } private void TryResolveCrowdTrigger( Collider2D other) { EnemyController enemy = other.GetComponentInParent(); if (enemy == null || !enemy.IsCrowd) { return; } Vector2 hitPosition = Vector2.Lerp( transform.position, enemy.transform.position, 0.5f); TryResolveNormalContact(enemy, hitPosition); } private void TryResolveNormalContact( EnemyController enemy, Vector2 hitPosition) { if (!RunManager.GameplayInputEnabled || playerController.IsHurtMovementLocked || playerController.IsDamageKnockbackActive || dashController.IsDashing || (activeArtifactController != null && activeArtifactController.LocksMovement)) { return; } if (enemy.IsDead) { return; } if (nextNormalHitTimes.TryGetValue( enemy.GetInstanceID(), out float nextNormalHitTime) && Time.time < nextNormalHitTime) { return; } Vector2 toEnemy = ((Vector2)enemy.transform.position - (Vector2)transform.position).normalized; if (playerController.AttackIntentVelocity.magnitude < minimumAttackSpeed || playerController.MoveDirection.sqrMagnitude <= 0f || Vector2.Dot(playerController.MoveDirection, toEnemy) < minimumApproachDot) { return; } Vector2 enemyToPlayer = ((Vector2)transform.position - (Vector2)enemy.transform.position).normalized; HitSide side = BumpCombatMath.ClassifySide( enemy.FacingDirection, enemyToPlayer); if (enemy.IsGroggyTierGated && enemy.IsDamageInvulnerable) { playerController.ApplyImpactRecoil( enemyToPlayer, shieldedPlayerImpactRecoilDistance); nextNormalHitTimes[enemy.GetInstanceID()] = Time.time + normalHitInterval; CombatFeedback.ShowBlockedBump( gameObject, hitPosition, playerController.MoveDirection); return; } ResolveEnemy( enemy, false, hitPosition, transform.position, playerController.MoveDirection, side); } public void ResolveDashPath( Vector2 start, Vector2 direction, float distance, bool isStrongDash = false, float? damageOverride = null, float? knockbackOverride = null, string sourceId = null, DamageTag damageTag = DamageTag.Collision, ArtifactColor? artifactColor = null, int castIdentity = 0, ActiveArtifactEffect? artifactEffect = null) { ResolveDashPathInternal( start, direction, distance, isStrongDash, damageOverride, knockbackOverride, sourceId, damageTag, artifactColor, castIdentity, artifactEffect, true); } public void ResolveDashPathSegment( Vector2 start, Vector2 direction, float distance, bool isStrongDash = false, float? damageOverride = null, float? knockbackOverride = null, string sourceId = null, DamageTag damageTag = DamageTag.Collision, ArtifactColor? artifactColor = null, int castIdentity = 0, ActiveArtifactEffect? artifactEffect = null) { ResolveDashPathInternal( start, direction, distance, isStrongDash, damageOverride, knockbackOverride, sourceId, damageTag, artifactColor, castIdentity, artifactEffect, false); } private void ResolveDashPathInternal( Vector2 start, Vector2 direction, float distance, bool isStrongDash, float? damageOverride, float? knockbackOverride, string sourceId, DamageTag damageTag, ArtifactColor? artifactColor, int castIdentity, ActiveArtifactEffect? artifactEffect, bool resetDashTargets) { if (resetDashTargets) { dashTargets.Clear(); } Vector2 castStart = start; float radius = GameplayConstants.Current.Combat.DashFallbackCastRadius; if (bodyCollider != null) { Vector2 colliderOffset = (Vector2)bodyCollider.bounds.center - (Vector2)transform.position; castStart += colliderOffset; radius = Mathf.Max( bodyCollider.bounds.extents.x, bodyCollider.bounds.extents.y) + dashContactSkin; } int hitCount = Physics2D.CircleCast( castStart, radius, direction, ContactFilter2D.noFilter, dashHits, distance); for (int i = 0; i < hitCount; i++) { EnemyController enemy = dashHits[i].collider.GetComponentInParent(); if (enemy == null || enemy.IsDead || !dashTargets.Add(enemy.GetInstanceID())) { continue; } Vector2 hitPosition = dashHits[i].point; if (hitPosition == Vector2.zero) { hitPosition = dashHits[i].collider.ClosestPoint( castStart + direction * dashHits[i].distance); } Vector2 attackerPositionAtHit = castStart + direction * dashHits[i].distance; ResolveEnemy( enemy, true, hitPosition, attackerPositionAtHit, direction, null, isStrongDash, damageOverride, knockbackOverride, sourceId, damageTag, artifactColor, castIdentity, artifactEffect); } } private bool ResolveEnemy( EnemyController enemy, bool isDash, Vector2 hitPosition, Vector2 attackerPosition, Vector2 attackDirection, HitSide? knownSide = null, bool isStrongDash = false, float? damageOverride = null, float? knockbackOverride = null, string sourceId = null, DamageTag damageTag = DamageTag.Collision, ArtifactColor? artifactColor = null, int castIdentity = 0, ActiveArtifactEffect? artifactEffect = null) { Vector2 enemyToPlayer = (attackerPosition - (Vector2)enemy.transform.position).normalized; HitSide side = knownSide ?? BumpCombatMath.ClassifySide(enemy.FacingDirection, enemyToPlayer); bool isEventEnemy = enemy.IsEventEnemy; bool isOffsetHit = !isDash && BumpCombatMath.IsOffsetHit( side, attackDirection, attackerPosition, enemy.transform.position, minimumOffsetDistance); float rawDamage = damageOverride ?? (isStrongDash ? strongDashDamage : isDash ? dashDamage : baseDamage); float finalDamage = BumpCombatMath.ApplySideMultiplier( playerStats.CalculateDamage(rawDamage, damageTag), side, isOffsetHit); Vector2 knockbackDirection = -enemyToPlayer; bool suppressOrdinaryEventReaction = !isDash && isEventEnemy; float appliedKnockback = knockbackOverride ?? (isStrongDash ? strongDashKnockbackForce : isDash ? dashKnockbackForce : side == HitSide.Front ? knockbackForce * frontKnockbackMultiplier : knockbackForce); if (suppressOrdinaryEventReaction) { appliedKnockback = 0f; } EnemyArtifactHitResult artifactHitResult = EnemyArtifactHitResult.Rejected; bool acceptedHit; if (artifactColor.HasValue) { artifactHitResult = enemy.TryTakeArtifactHit( finalDamage, knockbackDirection, appliedKnockback, artifactColor.Value, castIdentity, !suppressOrdinaryEventReaction); acceptedHit = artifactHitResult != EnemyArtifactHitResult.Rejected; } else { acceptedHit = enemy.TryTakeDamage( finalDamage, knockbackDirection, appliedKnockback, !suppressOrdinaryEventReaction, !isDash); } if (!acceptedHit) { return false; } if (artifactHitResult == EnemyArtifactHitResult.Shielded) { if (enemy.LastArtifactContactCounted && artifactEffect.HasValue) { CombatEvents.RaiseArtifactEffectiveHit( enemy.gameObject, castIdentity, artifactEffect.Value, isStrongDash, true); CombatFeedback.ShowArtifactImpact( CombatHitResult.ForArtifactContact( gameObject, enemy.gameObject, artifactEffect.Value, isStrongDash, side, knockbackDirection, hitPosition, damageTag, sourceId, isOffsetHit)); } // The contact has already advanced the groggy stagger pip. // A shielded dash must not become a bump, interrupt, launch, // recoil, gauge charge, or damage-hit feedback. return false; } float damageTaken = enemy.LastAppliedDamage; if (damageTaken <= 0f) { return false; } if (artifactColor.HasValue && artifactEffect.HasValue) { CombatEvents.RaiseArtifactEffectiveHit( enemy.gameObject, castIdentity, artifactEffect.Value, isStrongDash, false); } float reportedDamage = enemy.LastReportedDamage; bool protectionBegan = enemy.IsDamageInvulnerable; if (protectionBegan) { appliedKnockback = 0f; } if (!isDash) { playerController.TryPlayBumpAttackAnimation( side == HitSide.Back); } if (!isDash) { nextNormalHitTimes[enemy.GetInstanceID()] = Time.time + normalHitInterval; } bool launchSucceeded = false; if (!protectionBegan && isStrongDash) { enemy.TryInterruptAttackFromLaunch(); if (CanLaunchAfterHit(enemy.CanBeLaunched, enemy.IsDead)) { launchSucceeded = true; enemy.PlayLaunchVisual( knockbackDirection, strongDashLaunchHeight, strongDashLaunchDuration); } else if (ShouldShowLaunchResist(enemy.CanBeLaunched)) { CombatEvents.RaiseLaunchResisted(enemy.gameObject); } } else if (!protectionBegan && side == HitSide.Back) { bool broke = enemy.TryInterruptAttackFromBackHit(); if (broke && !isDash) { CombatEvents.RaiseRearAttackCancelled(enemy.gameObject); } if (suppressOrdinaryEventReaction && broke) { enemy.PlayHurtAnimation(); } } if (!isDash && !protectionBegan) { if (!enemy.IsCrowd && BumpCombatMath.AppliesPlayerImpactRecoil(side, isEventEnemy)) { playerController.ApplyImpactRecoil( enemyToPlayer, side == HitSide.Front ? frontPlayerImpactRecoilDistance : side == HitSide.Back && isEventEnemy ? eventBackPlayerImpactRecoilDistance : playerImpactRecoilDistance); } activeArtifactController?.AddNormalHitCharge(); } CombatEvents.RaiseValidHit(new CombatHitResult( gameObject, enemy.gameObject, isDash, side, reportedDamage, knockbackDirection, appliedKnockback, hitPosition, isOffsetHit, isStrongDash, damageTag, sourceId, true, true, launchSucceeded, artifactEffect.HasValue, artifactEffect.GetValueOrDefault(), artifactEffect.HasValue && isStrongDash)); return true; } public static bool CanLaunchAfterHit(bool canBeLaunched, bool isDead) { return canBeLaunched && !isDead; } public static bool ShouldShowLaunchResist(bool canBeLaunched) { return !canBeLaunched; } } }