using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class CombatFeedbackV2PlayModeTests { private enum ShieldApproach { Front, Side, Back, } private sealed class RecoilObservation { public bool Observed; public float Distance; public Vector2 Direction; public HitSide Side; } private static readonly BindingFlags NonPublicInstance = BindingFlags.Instance | BindingFlags.NonPublic; private bool previousGrantCatalogForTests; private bool previousProductionModeForTests; private float previousTimeScale; private bool previousRunInBackground; private InputSettings.BackgroundBehavior previousBackgroundBehavior; private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior; private bool inputSettingsCaptured; private Keyboard virtualKeyboard; [SetUp] public void SetUp() { previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; previousProductionModeForTests = RunManager.ForceProductionModeForTests; previousTimeScale = Time.timeScale; previousRunInBackground = Application.runInBackground; ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [TearDown] public void TearDown() { if (virtualKeyboard != null && virtualKeyboard.added) { InputSystem.QueueStateEvent(virtualKeyboard, new KeyboardState()); InputSystem.RemoveDevice(virtualKeyboard); } virtualKeyboard = null; if (inputSettingsCaptured) { InputSystem.settings.backgroundBehavior = previousBackgroundBehavior; InputSystem.settings.editorInputBehaviorInPlayMode = previousEditorInputBehavior; Application.runInBackground = previousRunInBackground; inputSettingsCaptured = false; } ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests; RunManager.ForceProductionModeForTests = previousProductionModeForTests; Time.timeScale = previousTimeScale; } [UnityTest] public IEnumerator ShieldedNormalBump_FrontUsesStrongRecoilWithoutProgress() { yield return RunShieldedNormalBumpCase(ShieldApproach.Front); } [UnityTest] public IEnumerator ShieldedNormalBump_SideUsesStrongRecoilWithoutProgress() { yield return RunShieldedNormalBumpCase(ShieldApproach.Side); } [UnityTest] public IEnumerator ShieldedNormalBump_BackUsesStrongRecoilWithoutProgress() { yield return RunShieldedNormalBumpCase(ShieldApproach.Back); } [UnityTest] public IEnumerator BlockedBumpVisual_PausesAndCleansOnDisable() { yield return LoadCombatScene(); PrepareScene(out PlayerController player); CombatFeedback feedback = player.GetComponent(); Assert.That(feedback, Is.Not.Null); GameObject otherAttacker = new("Other Blocked Bump Attacker"); float previousScale = Time.timeScale; try { Time.timeScale = 0f; Vector2 hitPosition = player.transform.position; CombatFeedback.ShowBlockedBump( player.gameObject, hitPosition, Vector2.right); Assert.That(feedback.ActiveBlockedBumpVisualCount, Is.EqualTo(1)); CombatFeedback.ShowBlockedBump( otherAttacker, hitPosition, Vector2.right); Assert.That( feedback.ActiveBlockedBumpVisualCount, Is.EqualTo(1), "A different attacker must not receive this feedback."); yield return new WaitForSecondsRealtime(0.16f); Assert.That( feedback.ActiveBlockedBumpVisualCount, Is.EqualTo(1), "The blocked visual must pause with scaled time."); feedback.enabled = false; Assert.That(feedback.ActiveBlockedBumpVisualCount, Is.Zero); feedback.enabled = true; CombatFeedback.ShowBlockedBump( player.gameObject, hitPosition, Vector2.right); Assert.That(feedback.ActiveBlockedBumpVisualCount, Is.EqualTo(1)); Time.timeScale = 1f; yield return new WaitForSeconds(0.18f); Assert.That(feedback.ActiveBlockedBumpVisualCount, Is.Zero); } finally { feedback.enabled = true; Time.timeScale = previousScale; UnityEngine.Object.Destroy(otherAttacker); } } [UnityTest] public IEnumerator CycloneNormal_DealsThreeRuntimeTicksToAnOrdinaryTarget() { yield return RunOrdinaryCycloneCase(false, 3); } [UnityTest] public IEnumerator CycloneCharged_DealsFiveRuntimeTicksToAnOrdinaryTarget() { yield return RunOrdinaryCycloneCase(true, 5); } [UnityTest] public IEnumerator CycloneNormal_ContactsTargetsAroundAllOrbitDirections() { yield return RunCycloneOrbitDirectionCase(false); } [UnityTest] public IEnumerator CycloneCharged_ContactsTargetsAroundAllOrbitDirections() { yield return RunCycloneOrbitDirectionCase(true); } [UnityTest] public IEnumerator CycloneChargedOrbit_ReachesBeyondNormalEllipse() { yield return RunCycloneChargedReachCase(); } [UnityTest] public IEnumerator CycloneNormal_LargeColliderOverlapCountsWhenAnchorIsOutside() { yield return RunProtectedEllipseCase(false); } [UnityTest] public IEnumerator CycloneCharged_LargeColliderOverlapCountsWhenAnchorIsOutside() { yield return RunProtectedEllipseCase(true); } [UnityTest] public IEnumerator CycloneTrueOutsideTarget_IsNotCounted() { yield return LoadCombatScene(); SpawnDirector director = PrepareScene(out PlayerController player); ActiveArtifactController artifacts = player.GetComponent(); PlayerHealth health = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(artifacts, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); SetCycloneTestFacing(player, playerBody, Vector2.right); // Keep the scaled NecroGolem body clear of the normal Cyclone ellipse, // rather than relying on its anchor being outside while its body overlaps. EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.NecroGolem), playerBody.position + Vector2.left * 4f, Quaternion.identity); ConfigureEnemyHealthMultiplier(enemy, 10f); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; Physics2D.IgnoreCollision( player.GetComponent(), enemy.GetComponent()); yield return WaitForReady(enemy); Vector2 targetPosition = playerBody.position + Vector2.left * 1.8f; Rigidbody2D enemyBody = enemy.GetComponent(); enemyBody.linearVelocity = Vector2.zero; enemyBody.position = targetPosition; enemy.transform.position = targetPosition; enemy.enabled = false; enemyBody.bodyType = RigidbodyType2D.Static; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); Assert.That(enemy.ShieldColor, Is.EqualTo(definition.ArtifactColor)); Physics2D.SyncTransforms(); GetCycloneEllipse( definition, false, playerBody.position, player.transform, out Vector2 ellipseCenter, out Vector2 ellipseRadii); Assert.That( EllipseDistance(enemy.GroundAnchorPosition - ellipseCenter, ellipseRadii), Is.GreaterThan(1f)); Assert.That( ColliderOverlapsEllipse( enemy.GetComponent(), ellipseCenter, ellipseRadii), Is.False); float healthBefore = enemy.CurrentHealth; Assert.That(artifacts.TryUseCurrent(false), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); UnityEngine.Object.Destroy(enemy.gameObject); } [UnityTest] public IEnumerator CycloneProtectedContacts_RequireTwoMatchingActivationsAndGroggyCycloneDealsDamage() { yield return LoadCombatScene(); SpawnDirector director = PrepareScene(out PlayerController player); ActiveArtifactController artifacts = player.GetComponent(); PlayerHealth health = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(artifacts, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); SetCycloneTestFacing(player, playerBody, Vector2.right); EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.NecroGolem), playerBody.position + new Vector2(0.28f, 0.08f), Quaternion.identity); ConfigureEnemyHealthMultiplier(enemy, 10f); yield return WaitForReady(enemy); yield return WaitForWarning(enemy); SetPrivateField(enemy, "shieldColor", artifacts.CurrentArtifact.ArtifactColor); Assert.That(enemy.ShieldColor, Is.EqualTo(artifacts.CurrentArtifact.ArtifactColor)); Vector2 protectedTargetPosition = playerBody.position + new Vector2(0.28f, 0.08f); Rigidbody2D enemyBody = enemy.GetComponent(); enemyBody.linearVelocity = Vector2.zero; enemyBody.position = protectedTargetPosition; enemy.transform.position = protectedTargetPosition; enemyBody.bodyType = RigidbodyType2D.Static; Physics2D.SyncTransforms(); float healthBefore = enemy.CurrentHealth; Assert.That(artifacts.TryUseCurrent(false), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1)); Assert.That(enemy.IsGroggy, Is.False); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); // A mid-boss shield counts separate activations of its current // color. A different-color artifact remains shielded and does not // advance the same cycle. SelectArtifact(artifacts, ActiveArtifactEffect.Pulse); Assert.That(artifacts.TryUseCurrent(false), Is.True); yield return null; Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1)); Assert.That(enemy.IsGroggy, Is.False); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); Assert.That(artifacts.TryUseCurrent(false), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(2)); Assert.That(enemy.ShieldHitsRemaining, Is.Zero); Assert.That(enemy.IsGroggy, Is.True); SetPrivateField( artifacts, "k__BackingField", artifacts.MaxGauge); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); int damageHitCount = 0; Action onHit = result => { if (result.Target == enemy.gameObject && result.SourceId == "cyclone" && result.Damage > 0f) { damageHitCount++; } }; CombatEvents.OnValidHit += onHit; try { float groggyHealthBefore = enemy.CurrentHealth; Assert.That(artifacts.TryUseCurrent(true), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(damageHitCount, Is.GreaterThan(0)); Assert.That(enemy.CurrentHealth, Is.LessThan(groggyHealthBefore)); } finally { CombatEvents.OnValidHit -= onHit; } UnityEngine.Object.Destroy(enemy.gameObject); } private IEnumerator RunShieldedNormalBumpCase(ShieldApproach approach) { yield return LoadCombatScene(); SpawnDirector director = PrepareScene(out PlayerController player); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); CombatFeedback feedback = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(feedback, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); SetPrivateField(artifacts, "movementGainPerSecond", 0f); SetPrivateField(artifacts, "k__BackingField", 0f); Vector2 targetPosition = playerBody.position + Vector2.right * 0.9f; EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.ArmoredSkeleton), targetPosition, Quaternion.identity); enemy.ConfigureRunEventEnemy( RunTimedEvent.Elite, CreateStableEliteTuning()); yield return WaitForReady(enemy); yield return WaitForWarning(enemy); Assert.That(enemy.FacingDirection.x, Is.LessThan(-0.8f)); Keyboard keyboard = BeginVirtualKeyboardInput(); int validHitCount = 0; Action onValidHit = result => { if (result.Attacker == player.gameObject && result.Target == enemy.gameObject && result.IsOrdinaryBump) { validHitCount++; } }; CombatEvents.OnValidHit += onValidHit; RecoilObservation observation = new(); try { yield return DriveToShieldContact( keyboard, player, playerBody, enemy.GetComponent(), approach, observation); Assert.That(observation.Observed, Is.True); Assert.That(observation.Distance, Is.EqualTo(0.75f).Within(0.15f)); Assert.That(observation.Side, Is.EqualTo(ToHitSide(approach))); Assert.That( feedback.ActiveBlockedBumpVisualCount, Is.EqualTo(1), "Protected contact must emit one blocked bump visual at recoil."); Assert.That( feedback.ActiveBumpImpactVisualCount, Is.Zero, "Protected contact must not emit the success bump visual."); yield return new WaitForSeconds(0.04f); Assert.That( feedback.ActiveBlockedBumpVisualCount, Is.EqualTo(1), "The cadence gate must prevent duplicate blocked visuals."); float healthBefore = health.CurrentHealth; float enemyHealthBefore = enemy.CurrentHealth; float gaugeBefore = artifacts.CurrentGauge; Animator animator = enemy.GetComponent(); Assert.That(animator, Is.Not.Null); yield return null; Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); Assert.That(enemy.CurrentHealth, Is.EqualTo(enemyHealthBefore)); Assert.That(enemy.LastAppliedDamage, Is.Zero); Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero); Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBefore)); Assert.That(enemy.IsStunned, Is.False); Assert.That( animator.GetCurrentAnimatorStateInfo(0).IsName(PlayerController.HurtStateName), Is.False); Assert.That( validHitCount, Is.Zero, "Protected contact must not raise OnValidHit."); BumpCombatResolver resolver = player.GetComponent(); Assert.That(resolver, Is.Not.Null); FieldInfo nextHitTimesField = typeof(BumpCombatResolver).GetField( "nextNormalHitTimes", NonPublicInstance); Assert.That(nextHitTimesField, Is.Not.Null); IDictionary nextHitTimes = nextHitTimesField.GetValue(resolver) as IDictionary; Assert.That(nextHitTimes, Is.Not.Null); int enemyId = enemy.GetInstanceID(); Assert.That(nextHitTimes.Contains(enemyId), Is.True); float cooldownDeadline = Convert.ToSingle(nextHitTimes[enemyId]); bool earlySecondRecoil = false; Vector2 lastPosition = playerBody.position; while (Time.time < cooldownDeadline) { if (approach == ShieldApproach.Back) { QueueMovementTowardEnemy( keyboard, playerBody.position, enemy.GetComponent().position); } yield return new WaitForFixedUpdate(); if (Time.time >= cooldownDeadline) { break; } if (IsRecoilStep( playerBody.position - lastPosition, playerBody.position, enemy.GetComponent().position)) { earlySecondRecoil = true; break; } lastPosition = playerBody.position; } Assert.That( earlySecondRecoil, Is.False, "A shielded target must keep the normal per-target cooldown."); bool secondRecoil = false; float secondDeadline = Time.realtimeSinceStartup + 0.6f; while (Time.realtimeSinceStartup < secondDeadline) { if (approach == ShieldApproach.Back) { QueueMovementTowardEnemy( keyboard, playerBody.position, enemy.GetComponent().position); } yield return new WaitForFixedUpdate(); Vector2 delta = playerBody.position - lastPosition; if (IsRecoilStep( delta, playerBody.position, enemy.GetComponent().position)) { secondRecoil = true; break; } lastPosition = playerBody.position; } Assert.That( secondRecoil, Is.True, "A held physical contact should be eligible again after 0.25 seconds."); } finally { CombatEvents.OnValidHit -= onValidHit; InputSystem.QueueStateEvent(keyboard, new KeyboardState()); } UnityEngine.Object.Destroy(enemy.gameObject); } private IEnumerator RunCycloneOrbitDirectionCase(bool charged) { yield return LoadCombatScene(); PrepareScene(out PlayerController player); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); SetCycloneTestFacing(player, playerBody, Vector2.right); playerBody.position = Vector2.zero; Physics2D.SyncTransforms(); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; float normalMoveDistance = definition.NormalMoveDistance; float chargedMoveDistance = definition.ChargedMoveDistance; SetPrivateField(definition, "normalMoveDistance", 0f); SetPrivateField(definition, "chargedMoveDistance", 0f); GetCycloneEllipse( definition, charged, playerBody.position, player.transform, out Vector2 ellipseCenter, out Vector2 ellipseRadii); Vector2[] directions = { Vector2.right, Vector2.left, Vector2.up, Vector2.down, new Vector2(1f, 1f).normalized, new Vector2(-1f, 1f).normalized, new Vector2(1f, -1f).normalized, new Vector2(-1f, -1f).normalized, }; List targets = new(); foreach (Vector2 direction in directions) { EnemyController enemy = UnityEngine.Object.Instantiate( LoadWarlockFixture(), ellipseCenter + Vector2.right * 4f, Quaternion.identity); ConfigureEnemyHealthMultiplier(enemy, 20f); Physics2D.IgnoreCollision( player.GetComponent(), enemy.GetComponent()); yield return WaitForReady(enemy); Vector2 offset = new Vector2( direction.x * ellipseRadii.x, direction.y * ellipseRadii.y) * 0.55f; Vector2 targetPosition = ellipseCenter + offset; Rigidbody2D enemyBody = enemy.GetComponent(); enemyBody.linearVelocity = Vector2.zero; enemyBody.position = targetPosition; enemy.transform.position = targetPosition; enemyBody.bodyType = RigidbodyType2D.Static; enemy.enabled = false; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); Assert.That(enemy.ShieldColor, Is.EqualTo(definition.ArtifactColor)); Physics2D.SyncTransforms(); Assert.That( EllipseDistance( enemy.GroundAnchorPosition - ellipseCenter, ellipseRadii), Is.LessThan(1f), $"{(charged ? "Charged" : "Normal")} orbit target at {direction} must start inside."); targets.Add(enemy); } List hitTargets = new(); Action onHit = result => { if (result.SourceId == definition.ArtifactId && !hitTargets.Contains(result.Target)) { hitTargets.Add(result.Target); } }; CombatEvents.OnValidHit += onHit; try { Assert.That(artifacts.TryUseCurrent(charged), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(hitTargets, Has.Count.EqualTo(directions.Length)); foreach (EnemyController target in targets) { Assert.That( hitTargets, Does.Contain(target.gameObject), $"{(charged ? "Charged" : "Normal")} orbit missed {target.name}."); } } finally { CombatEvents.OnValidHit -= onHit; foreach (EnemyController target in targets) { if (target != null) { Physics2D.IgnoreCollision( player.GetComponent(), target.GetComponent(), false); UnityEngine.Object.Destroy(target.gameObject); } } SetPrivateField(definition, "normalMoveDistance", normalMoveDistance); SetPrivateField(definition, "chargedMoveDistance", chargedMoveDistance); } } private IEnumerator RunCycloneChargedReachCase() { yield return LoadCombatScene(); PrepareScene(out PlayerController player); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); SetCycloneTestFacing(player, playerBody, Vector2.right); playerBody.position = Vector2.zero; Physics2D.SyncTransforms(); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; float normalMoveDistance = definition.NormalMoveDistance; float chargedMoveDistance = definition.ChargedMoveDistance; SetPrivateField(definition, "normalMoveDistance", 0f); SetPrivateField(definition, "chargedMoveDistance", 0f); GetCycloneEllipse( definition, false, playerBody.position, player.transform, out Vector2 normalCenter, out Vector2 normalRadii); GetCycloneEllipse( definition, true, playerBody.position, player.transform, out Vector2 chargedCenter, out Vector2 chargedRadii); EnemyController enemy = UnityEngine.Object.Instantiate( LoadWarlockFixture(), chargedCenter + Vector2.right * 4f, Quaternion.identity); ConfigureEnemyHealthMultiplier(enemy, 20f); Physics2D.IgnoreCollision( player.GetComponent(), enemy.GetComponent()); yield return WaitForReady(enemy); enemy.transform.localScale = Vector3.one * 0.05f; Rigidbody2D enemyBody = enemy.GetComponent(); enemyBody.linearVelocity = Vector2.zero; Vector2 targetPosition = playerBody.position + GetCycloneHitCenterOffsetForTest( definition, player.transform, true) + Vector2.right * 1.3f; enemyBody.position = targetPosition; enemy.transform.position = targetPosition; enemyBody.bodyType = RigidbodyType2D.Static; enemy.enabled = false; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); Assert.That(enemy.ShieldColor, Is.EqualTo(definition.ArtifactColor)); Physics2D.SyncTransforms(); GetCycloneEllipse( definition, false, playerBody.position, player.transform, out normalCenter, out normalRadii); GetCycloneEllipse( definition, true, playerBody.position, player.transform, out chargedCenter, out chargedRadii); Assert.That( EllipseDistance(enemy.GroundAnchorPosition - normalCenter, normalRadii), Is.GreaterThan(1f)); Assert.That( EllipseDistance(enemy.GroundAnchorPosition - chargedCenter, chargedRadii), Is.LessThan(1f)); Assert.That( ColliderOverlapsEllipse( enemy.GetComponent(), normalCenter, normalRadii), Is.False, "The small target must be outside the normal orbit and its body."); Assert.That( ColliderOverlapsEllipse( enemy.GetComponent(), chargedCenter, chargedRadii), Is.True, "The same small target must be inside the charged orbit."); float healthBefore = enemy.CurrentHealth; int cycloneHits = 0; Action onHit = result => { if (result.Target == enemy.gameObject && result.SourceId == definition.ArtifactId) { cycloneHits++; } }; CombatEvents.OnValidHit += onHit; try { Assert.That(artifacts.TryUseCurrent(false), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); Assert.That(cycloneHits, Is.Zero); yield return new WaitForSecondsRealtime(0.1f); artifacts.AddMovementCharge(1000f); Assert.That(artifacts.TryUseCurrent(true), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(enemy.CurrentHealth, Is.LessThan(healthBefore)); Assert.That(cycloneHits, Is.GreaterThan(0)); } finally { SetPrivateField(definition, "normalMoveDistance", normalMoveDistance); SetPrivateField(definition, "chargedMoveDistance", chargedMoveDistance); CombatEvents.OnValidHit -= onHit; if (enemy != null) { UnityEngine.Object.Destroy(enemy.gameObject); } } } private IEnumerator RunOrdinaryCycloneCase(bool charged, int expectedHitCount) { yield return LoadCombatScene(); PrepareScene(out PlayerController player); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); SetCycloneTestFacing(player, playerBody, Vector2.right); EnemyController enemy = UnityEngine.Object.Instantiate( LoadWarlockFixture(), playerBody.position + new Vector2(0.28f, 0.08f), Quaternion.identity); ConfigureEnemyHealthMultiplier(enemy, 20f); yield return WaitForReady(enemy); yield return WaitForWarning(enemy); int hitCount = 0; Action onHit = result => { if (result.Target == enemy.gameObject && result.SourceId == "cyclone") { hitCount++; } }; CombatEvents.OnValidHit += onHit; try { float healthBefore = enemy.CurrentHealth; Assert.That(artifacts.TryUseCurrent(charged), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(hitCount, Is.EqualTo(expectedHitCount)); Assert.That(enemy.CurrentHealth, Is.LessThan(healthBefore)); Assert.That(enemy.IsDead, Is.False); } finally { CombatEvents.OnValidHit -= onHit; } UnityEngine.Object.Destroy(enemy.gameObject); } private IEnumerator RunProtectedEllipseCase(bool charged) { yield return LoadCombatScene(); SpawnDirector director = PrepareScene(out PlayerController player); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); health.GrantInvulnerability(60f); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); SetCycloneTestFacing(player, playerBody, Vector2.right); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; float offsetX = 0.7f; Vector2 spawnPosition = playerBody.position + Vector2.right * 4f; EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.NecroGolem), spawnPosition, Quaternion.identity); ConfigureEnemyHealthMultiplier(enemy, 10f); Physics2D.IgnoreCollision( player.GetComponent(), enemy.GetComponent()); yield return WaitForReady(enemy); Vector2 targetPosition = playerBody.position + new Vector2(offsetX, 0.5f); Rigidbody2D enemyBody = enemy.GetComponent(); enemyBody.linearVelocity = Vector2.zero; enemyBody.position = targetPosition; enemy.transform.position = targetPosition; enemy.enabled = false; enemyBody.bodyType = RigidbodyType2D.Static; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); Assert.That(enemy.ShieldColor, Is.EqualTo(definition.ArtifactColor)); Physics2D.SyncTransforms(); GetCycloneEllipse( definition, charged, playerBody.position, player.transform, out Vector2 ellipseCenter, out Vector2 ellipseRadii); Assert.That( EllipseDistance(enemy.GroundAnchorPosition - ellipseCenter, ellipseRadii), Is.GreaterThan(1f), "The target root must be outside the authored ground ellipse."); Assert.That( ColliderOverlapsEllipse( enemy.GetComponent(), ellipseCenter, ellipseRadii), Is.True, "The large NecroGolem body must overlap the authored ellipse."); float healthBefore = enemy.CurrentHealth; Assert.That(artifacts.TryUseCurrent(charged), Is.True); yield return WaitForArtifactFinish(artifacts); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1)); Assert.That(enemy.IsGroggy, Is.False); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); UnityEngine.Object.Destroy(enemy.gameObject); } private IEnumerator DriveToShieldContact( Keyboard keyboard, PlayerController player, Rigidbody2D playerBody, Rigidbody2D enemyBody, ShieldApproach approach, RecoilObservation observation) { switch (approach) { case ShieldApproach.Front: yield return HoldAndWatchForRecoil( keyboard, Key.RightArrow, player, playerBody, enemyBody, 2f, observation); break; case ShieldApproach.Side: yield return HoldAndWatchForRecoil( keyboard, Key.DownArrow, player, playerBody, enemyBody, 0.4f, observation); if (!observation.Observed) { yield return HoldAndWatchForRecoil( keyboard, Key.RightArrow, player, playerBody, enemyBody, 0.3f, observation); } if (!observation.Observed) { yield return HoldAndWatchForRecoil( keyboard, Key.UpArrow, player, playerBody, enemyBody, 0.5f, observation); } break; case ShieldApproach.Back: yield return HoldAndWatchForRecoil( keyboard, Key.DownArrow, player, playerBody, enemyBody, 0.4f, observation); if (!observation.Observed) { yield return HoldAndWatchForRecoil( keyboard, Key.RightArrow, player, playerBody, enemyBody, 0.45f, observation); } if (!observation.Observed) { yield return HoldAndWatchForRecoil( keyboard, Key.UpArrow, player, playerBody, enemyBody, 0.4f, observation); } break; } } private static IEnumerator HoldAndWatchForRecoil( Keyboard keyboard, Key key, PlayerController player, Rigidbody2D playerBody, Rigidbody2D enemyBody, float duration, RecoilObservation observation) { InputSystem.QueueStateEvent(keyboard, new KeyboardState(key)); Vector2 previousPosition = playerBody.position; float deadline = Time.realtimeSinceStartup + duration; while (Time.realtimeSinceStartup < deadline && !observation.Observed) { yield return new WaitForFixedUpdate(); Vector2 currentPosition = playerBody.position; Vector2 delta = currentPosition - previousPosition; if (IsRecoilStep(delta, currentPosition, enemyBody.position)) { observation.Observed = true; observation.Distance = delta.magnitude; observation.Direction = delta.normalized; observation.Side = BumpCombatMath.ClassifySide( Vector2.left, (currentPosition - enemyBody.position).normalized); yield break; } previousPosition = currentPosition; } } private static bool IsRecoilStep( Vector2 delta, Vector2 playerPosition, Vector2 enemyPosition) { if (delta.magnitude < 0.5f) { return false; } Vector2 awayFromEnemy = (playerPosition - enemyPosition).normalized; return Vector2.Dot(delta.normalized, awayFromEnemy) >= 0.65f; } private static void QueueMovementTowardEnemy( Keyboard keyboard, Vector2 playerPosition, Vector2 enemyPosition) { Vector2 direction = enemyPosition - playerPosition; bool hasHorizontal = Mathf.Abs(direction.x) > 0.05f; bool hasVertical = Mathf.Abs(direction.y) > 0.05f; if (hasHorizontal && hasVertical) { InputSystem.QueueStateEvent( keyboard, new KeyboardState( direction.x < 0f ? Key.LeftArrow : Key.RightArrow, direction.y < 0f ? Key.DownArrow : Key.UpArrow)); } else if (hasHorizontal) { InputSystem.QueueStateEvent( keyboard, new KeyboardState( direction.x < 0f ? Key.LeftArrow : Key.RightArrow)); } else if (hasVertical) { InputSystem.QueueStateEvent( keyboard, new KeyboardState( direction.y < 0f ? Key.DownArrow : Key.UpArrow)); } else { InputSystem.QueueStateEvent(keyboard, new KeyboardState()); } } private SpawnDirector PrepareScene(out PlayerController player) { SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); player = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); director.enabled = false; RunMissionTracker missionTracker = UnityEngine.Object.FindAnyObjectByType(); if (missionTracker != null) { missionTracker.enabled = false; // RunMissionTracker subscribes in Start and has no OnDisable; // stop its event handlers from completing a mission during // isolated combat feedback tests. SetPrivateField(missionTracker, "runHasStarted", false); } DisableSceneEnemies(); return director; } private Keyboard BeginVirtualKeyboardInput() { inputSettingsCaptured = true; previousBackgroundBehavior = InputSystem.settings.backgroundBehavior; previousEditorInputBehavior = InputSystem.settings.editorInputBehaviorInPlayMode; InputSystem.settings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; InputSystem.settings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; Application.runInBackground = true; virtualKeyboard = InputSystem.AddDevice(); virtualKeyboard.MakeCurrent(); return virtualKeyboard; } private static IEnumerator LoadCombatScene() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; yield return null; } private static IEnumerator WaitForReady(EnemyController enemy) { float deadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < deadline && enemy != null && (enemy.MaximumHealth <= 0f || enemy.State == EnemyState.Spawn)) { yield return null; } Assert.That(enemy, Is.Not.Null); Assert.That(enemy.MaximumHealth, Is.GreaterThan(0f)); Assert.That(enemy.State, Is.Not.EqualTo(EnemyState.Spawn)); } private static IEnumerator WaitForWarning(EnemyController enemy) { float deadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < deadline && enemy != null && enemy.State != EnemyState.Warning) { yield return new WaitForFixedUpdate(); } Assert.That(enemy, Is.Not.Null); Assert.That(enemy.State, Is.EqualTo(EnemyState.Warning)); } private static IEnumerator WaitForArtifactFinish( ActiveArtifactController artifacts) { yield return null; float deadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < deadline && artifacts != null && artifacts.IsExecutingArtifact) { yield return null; } Assert.That(artifacts, Is.Not.Null); Assert.That(artifacts.IsExecutingArtifact, Is.False); yield return new WaitForSecondsRealtime(0.05f); } private static void DisableSceneEnemies() { EnemyController[] enemies = UnityEngine.Object.FindObjectsByType( FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (EnemyController enemy in enemies) { enemy.gameObject.SetActive(false); } } private static void SelectArtifact( ActiveArtifactController artifacts, ActiveArtifactEffect effect) { for (int i = 0; i < artifacts.OwnedArtifactCount; i++) { if (artifacts.CurrentArtifact != null && artifacts.CurrentArtifact.Effect == effect) { return; } Assert.That(artifacts.SelectNext(), Is.True); } Assert.Fail($"Artifact {effect} was not found in the debug catalog."); } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; foreach (string fieldName in fieldNames) { FieldInfo field = typeof(SpawnDirector).GetField( fieldName, NonPublicInstance); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } foreach (EnemyController prefab in prefabs) { if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } Assert.Fail($"Could not find the {kind} catalog prefab."); return null; } private static EnemyController LoadWarlockFixture() { Type assetDatabaseType = Type.GetType( "UnityEditor.AssetDatabase, UnityEditor"); Assert.That( assetDatabaseType, Is.Not.Null, "The UnityEditor AssetDatabase must be available for the legacy Warlock fixture."); MethodInfo loadAssetAtPath = null; foreach (MethodInfo candidate in assetDatabaseType.GetMethods( BindingFlags.Public | BindingFlags.Static)) { if (candidate.Name == "LoadAssetAtPath" && candidate.IsGenericMethodDefinition && candidate.GetGenericArguments().Length == 1) { loadAssetAtPath = candidate; break; } } Assert.That(loadAssetAtPath, Is.Not.Null); GameObject prefab = loadAssetAtPath.MakeGenericMethod(typeof(GameObject)) .Invoke( null, new object[] { "Assets/_Project/Prefabs/Enemies/Warlock.prefab" }) as GameObject; Assert.That(prefab, Is.Not.Null); EnemyController controller = prefab.GetComponent(); Assert.That(controller, Is.Not.Null); return controller; } private static RunEventEnemyTuning CreateStableEliteTuning() { return RunEventEnemyTuning.Create( EnemyKind.ArmoredSkeleton, 1f, 0, 1f, Color.white, 1f, 1f, 20f, 1f, 1f, 1f, 1f, 1f, 1f, 2, 1f, 1f, 2, 1f, false); } private static HitSide ToHitSide(ShieldApproach approach) { return approach switch { ShieldApproach.Front => HitSide.Front, ShieldApproach.Side => HitSide.Side, ShieldApproach.Back => HitSide.Back, _ => HitSide.Side, }; } private static void GetCycloneEllipse( ActiveArtifactDefinition definition, bool charged, Vector2 origin, Transform playerTransform, out Vector2 center, out Vector2 radii) { float range = charged ? definition.ChargedRange : definition.NormalRange; float visualScale = range / (charged ? 1.25f : 0.85f); Vector3 shadowOffset = playerTransform.TransformVector( new Vector3(0f, -7.5f / 32f, 0f)); center = origin + (Vector2)shadowOffset + Vector2.up * (5f / 32f * visualScale); radii = (charged ? new Vector2(46f, 18f) : new Vector2(38.0625f, 15.03125f)) / 32f * visualScale; } private static float EllipseDistance(Vector2 offset, Vector2 radii) { return offset.x * offset.x / (radii.x * radii.x) + offset.y * offset.y / (radii.y * radii.y); } private static bool ColliderOverlapsEllipse( Collider2D collider, Vector2 center, Vector2 radii) { if (collider == null) { return false; } Vector2 closestToCenter = collider.ClosestPoint(center); if (EllipseDistance(closestToCenter - center, radii) <= 1f) { return true; } const int sampleCount = 128; for (int i = 0; i < sampleCount; i++) { float angle = i * Mathf.PI * 2f / sampleCount; Vector2 point = center + new Vector2( Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y); if (collider.OverlapPoint(point)) { return true; } } return false; } private static void SetCycloneTestFacing( PlayerController player, Rigidbody2D playerBody, Vector2 facing) { SetPrivateField(player, "k__BackingField", facing); SetPrivateField(player, "k__BackingField", Vector2.zero); playerBody.linearVelocity = Vector2.zero; } private static Vector2 GetCycloneHitCenterOffsetForTest( ActiveArtifactDefinition definition, Transform playerTransform, bool charged) { GetCycloneEllipse( definition, charged, Vector2.zero, playerTransform, out Vector2 center, out _); return center; } private static void SetPlayerDirection( PlayerController player, Vector2 direction) { SetPrivateField(player, "k__BackingField", direction); SetPrivateField(player, "k__BackingField", direction); } private static void SetPrivateField( object target, string fieldName, object value) { FieldInfo field = target.GetType().GetField( fieldName, NonPublicInstance); Assert.That(field, Is.Not.Null, $"Missing private field {fieldName}."); field.SetValue(target, value); } private static void ConfigureEnemyHealthMultiplier( EnemyController enemy, float multiplier) { EnemyModel model = enemy?.GetComponent(); Assert.That(model, Is.Not.Null); model.ConfigureEventMultipliers(multiplier, 1f, 1f); } } }