326 lines
14 KiB
C#
326 lines
14 KiB
C#
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<EnemyAttack>();
|
|
LancerThrustVisual visual = lancer.GetComponent<LancerThrustVisual>();
|
|
SpriteRenderer source = lancer.GetComponent<SpriteRenderer>();
|
|
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<PlayerHealth>();
|
|
Rigidbody2D playerBody = health.GetComponent<Rigidbody2D>();
|
|
Collider2D playerCollider = health.GetComponent<Collider2D>();
|
|
EnemyController lancer = SpawnLancer();
|
|
EnemyAttack attack = lancer.GetComponent<EnemyAttack>();
|
|
lancer.enabled = false;
|
|
lancer.transform.position = Vector2.zero;
|
|
lancer.GetComponent<Rigidbody2D>().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<PlayerHealth>();
|
|
Rigidbody2D playerBody = health.GetComponent<Rigidbody2D>();
|
|
Collider2D playerCollider = health.GetComponent<Collider2D>();
|
|
EnemyController lancer = SpawnLancer();
|
|
EnemyAttack attack = lancer.GetComponent<EnemyAttack>();
|
|
lancer.enabled = false;
|
|
|
|
Rigidbody2D lancerBody = lancer.GetComponent<Rigidbody2D>();
|
|
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<SpawnDirector>();
|
|
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<EnemyController>();
|
|
Assert.That(spawnedLancer, Is.Not.Null);
|
|
|
|
EnemyController[] enemies = UnityEngine.Object.FindObjectsByType<EnemyController>(
|
|
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));
|
|
}
|
|
}
|
|
}
|