using System; using System.Collections; using System.Reflection; using BumpCombat.Enemies; using BumpCombat.Core; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class LancerThrustPlayModeTests { [SetUp] public void SetUp() { ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [TearDown] public void TearDown() { ArtifactRewardController.GrantCatalogForTests = false; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [UnityTest] public IEnumerator LancerThrust_VisualUsesLockedHandAndCleansUpAcrossDirections() { yield return LoadCombatPrototype(); EnemyController lancer = SpawnLancer(); EnemyAttack attack = lancer.GetComponent(); LancerThrustVisual visual = lancer.GetComponent(); SpriteRenderer source = lancer.GetComponent(); lancer.enabled = false; Vector2[] directions = { Vector2.right, Vector2.left, Vector2.up, new Vector2(-1f, 1f).normalized, }; foreach (Vector2 direction in directions) { attack.BeginWarning(direction, (Vector2)lancer.transform.position + direction); SetState(lancer, EnemyState.Warning, lancer.WarningDuration); yield return null; Assert.That(visual, Is.Not.Null); Assert.That(visual.IsVisible, Is.True); Assert.That(source.enabled, Is.False); Vector2 expectedHand = (Vector2)lancer.transform.position + LancerAttackMotion.GetHandAnchorOffset( direction, Mathf.Abs(lancer.transform.lossyScale.x)); Assert.That( Vector2.Distance(visual.SpearRenderer.transform.position, expectedHand), Is.LessThan(0.0001f)); Assert.That( Mathf.Abs(Mathf.DeltaAngle( visual.SpearRenderer.transform.eulerAngles.z, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg)), Is.LessThan(0.001f)); attack.EndAttack(true); yield return null; Assert.That(visual.IsVisible, Is.False); Assert.That(source.enabled, Is.True); } } [UnityTest] public IEnumerator LancerThrust_SweepsTipAndRetriesBlockedHitOnce() { yield return LoadCombatPrototype(); PlayerHealth health = UnityEngine.Object.FindAnyObjectByType(); Rigidbody2D playerBody = health.GetComponent(); Collider2D playerCollider = health.GetComponent(); EnemyController lancer = SpawnLancer(); EnemyAttack attack = lancer.GetComponent(); lancer.enabled = false; lancer.transform.position = Vector2.zero; lancer.GetComponent().position = Vector2.zero; Vector2 direction = Vector2.right; float scale = Mathf.Abs(lancer.transform.lossyScale.x); Vector2 futureTip = LancerAttackMotion.GetTipPosition( lancer.transform.position, direction, lancer.AttackLength, 1f, scale); PlacePlayerAtColliderCenter(playerBody, playerCollider, futureTip); Physics2D.SyncTransforms(); attack.BeginWarning(direction, futureTip); SetState(lancer, EnemyState.Warning, lancer.WarningDuration); float healthBefore = health.CurrentHealth; attack.Activate(); SetState(lancer, EnemyState.Active, lancer.ActiveDuration); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); attack.TickActive(lancer.ActiveDuration * 0.1f); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); health.GrantInvulnerability(0.25f); attack.TickActive(lancer.ActiveDuration * LancerAttackMotion.ActiveExtensionEndFraction); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); yield return new WaitForSecondsRealtime(0.3f); PlacePlayerAtColliderCenter(playerBody, playerCollider, futureTip); Physics2D.SyncTransforms(); Assert.That(health.IsInvulnerable, Is.False); attack.TickActive(lancer.ActiveDuration * LancerAttackMotion.ActiveExtensionEndFraction); Assert.That( health.CurrentHealth, Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f)); float immunityDeadline = Time.realtimeSinceStartup + health.InvulnerabilityDuration + 1f; while (health.IsInvulnerable && Time.realtimeSinceStartup < immunityDeadline) { yield return new WaitForSecondsRealtime(0.05f); } Assert.That(health.IsInvulnerable, Is.False, "The same thrust is retried only after the prior hit's real invulnerability ends."); PlacePlayerAtColliderCenter(playerBody, playerCollider, futureTip); Physics2D.SyncTransforms(); attack.TickActive(lancer.ActiveDuration * LancerAttackMotion.ActiveExtensionEndFraction); Assert.That( health.CurrentHealth, Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f)); // A completed extension must not retry the stale tip during return. attack.EndAttack(false); attack.BeginWarning(direction, futureTip); SetState(lancer, EnemyState.Warning, lancer.WarningDuration); attack.Activate(); SetState(lancer, EnemyState.Active, lancer.ActiveDuration); health.GrantInvulnerability(0.15f); float afterFirstThrust = health.CurrentHealth; attack.TickActive(lancer.ActiveDuration * LancerAttackMotion.ActiveExtensionEndFraction); yield return new WaitForSecondsRealtime(0.2f); Assert.That(health.IsInvulnerable, Is.False); attack.TickActive(lancer.ActiveDuration * 0.8f); Assert.That(health.CurrentHealth, Is.EqualTo(afterFirstThrust)); attack.EndAttack(false); SetState(lancer, EnemyState.Recovery, lancer.RecoveryDuration); attack.TickActive(lancer.ActiveDuration); Assert.That( health.CurrentHealth, Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f)); } [UnityTest] public IEnumerator LancerThrust_KnockbackRebaseDoesNotSweepDisplacement() { yield return LoadCombatPrototype(); PlayerHealth health = UnityEngine.Object.FindAnyObjectByType(); Rigidbody2D playerBody = health.GetComponent(); Collider2D playerCollider = health.GetComponent(); EnemyController lancer = SpawnLancer(); EnemyAttack attack = lancer.GetComponent(); lancer.enabled = false; Rigidbody2D lancerBody = lancer.GetComponent(); Vector2 direction = Vector2.right; Vector2 oldRoot = Vector2.zero; lancer.transform.position = oldRoot; lancerBody.position = oldRoot; Vector2 farFromPath = new Vector2(5f, 5f); PlacePlayerAtColliderCenter(playerBody, playerCollider, farFromPath); Physics2D.SyncTransforms(); attack.BeginWarning(direction, farFromPath); SetState(lancer, EnemyState.Warning, lancer.WarningDuration); attack.Activate(); SetState(lancer, EnemyState.Active, lancer.ActiveDuration); attack.TickActive(lancer.ActiveDuration * 0.1f); float scale = Mathf.Abs(lancer.transform.lossyScale.x); float firstProgress = 0.1f; Vector2 oldTip = LancerAttackMotion.GetTipPosition( oldRoot, direction, lancer.AttackLength, LancerAttackMotion.EvaluateActiveReachFraction(firstProgress), scale); Vector2 newRoot = new Vector2(0f, 3f); lancer.transform.position = newRoot; lancerBody.position = newRoot; Physics2D.SyncTransforms(); Vector2 newTipAtFirstProgress = LancerAttackMotion.GetTipPosition( newRoot, direction, lancer.AttackLength, LancerAttackMotion.EvaluateActiveReachFraction(firstProgress), scale); attack.RebaseActiveTipAfterExternalMotion(); float healthBefore = health.CurrentHealth; PlacePlayerAtColliderCenter( playerBody, playerCollider, Vector2.Lerp(oldTip, newTipAtFirstProgress, 0.5f)); Physics2D.SyncTransforms(); attack.TickActive(lancer.ActiveDuration * 0.2f); Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore)); Vector2 resumedTip = LancerAttackMotion.GetTipPosition( newRoot, direction, lancer.AttackLength, LancerAttackMotion.EvaluateActiveReachFraction(0.5f), scale); PlacePlayerAtColliderCenter(playerBody, playerCollider, resumedTip); Physics2D.SyncTransforms(); attack.TickActive(lancer.ActiveDuration * 0.5f); Assert.That( health.CurrentHealth, Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f)); } private static EnemyController SpawnLancer() { SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); director.enabled = false; director.DebugSpawnImmediate(3); // Lancer remains a legacy motion regression fixture even though // the stage catalog now contains only the requested ambient and // event roster. Load the preserved prefab directly in the Editor // test process instead of coupling this helper to wave contents. GameObject lancerPrefab = LoadLegacyPrefab( "Assets/_Project/Prefabs/Enemies/Lancer.prefab"); Assert.That(lancerPrefab, Is.Not.Null); EnemyController spawnedLancer = UnityEngine.Object.Instantiate( lancerPrefab, Vector3.zero, Quaternion.identity) .GetComponent(); Assert.That(spawnedLancer, Is.Not.Null); EnemyController[] enemies = UnityEngine.Object.FindObjectsByType( FindObjectsSortMode.InstanceID); foreach (EnemyController enemy in enemies) { if (enemy != spawnedLancer) { enemy.gameObject.SetActive(false); } } return spawnedLancer; } private static GameObject LoadLegacyPrefab(string assetPath) { Type assetDatabaseType = Type.GetType("UnityEditor.AssetDatabase, UnityEditor"); Assert.That(assetDatabaseType, Is.Not.Null, "UnityEditor.AssetDatabase is unavailable."); MethodInfo loadAssetAtPath = null; foreach (MethodInfo method in assetDatabaseType.GetMethods( BindingFlags.Public | BindingFlags.Static)) { if (method.Name == "LoadAssetAtPath" && method.IsGenericMethodDefinition && method.GetGenericArguments().Length == 1) { loadAssetAtPath = method; break; } } Assert.That(loadAssetAtPath, Is.Not.Null); MethodInfo loadPrefab = loadAssetAtPath.MakeGenericMethod(typeof(GameObject)); return loadPrefab.Invoke(null, new object[] { assetPath }) as GameObject; } private static void PlacePlayerAtColliderCenter( Rigidbody2D body, Collider2D collider, Vector2 desiredCenter) { Vector2 offset = (Vector2)collider.bounds.center - body.position; body.position = desiredCenter - offset; } private static void SetState( EnemyController controller, EnemyState state, float duration) { typeof(EnemyController) .GetMethod("EnterState", BindingFlags.Instance | BindingFlags.NonPublic) ?.Invoke(controller, new object[] { state, duration }); } private static IEnumerator LoadCombatPrototype() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; Assert.That(Time.timeScale, Is.EqualTo(1f)); } } }