Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/CombatFeedbackV2PlayModeTests.cs
T

1440 lines
56 KiB
C#

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<CombatFeedback>();
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<ActiveArtifactController>();
PlayerHealth health = player.GetComponent<PlayerHealth>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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<Collider2D>(),
enemy.GetComponent<Collider2D>());
yield return WaitForReady(enemy);
Vector2 targetPosition = playerBody.position + Vector2.left * 1.8f;
Rigidbody2D enemyBody = enemy.GetComponent<Rigidbody2D>();
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<Collider2D>(),
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<ActiveArtifactController>();
PlayerHealth health = player.GetComponent<PlayerHealth>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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<Rigidbody2D>();
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,
"<CurrentGauge>k__BackingField",
artifacts.MaxGauge);
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
int damageHitCount = 0;
Action<CombatHitResult> 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<PlayerHealth>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
CombatFeedback feedback = player.GetComponent<CombatFeedback>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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, "<CurrentGauge>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<CombatHitResult> 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<Rigidbody2D>(),
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<Animator>();
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<BumpCombatResolver>();
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<Rigidbody2D>().position);
}
yield return new WaitForFixedUpdate();
if (Time.time >= cooldownDeadline)
{
break;
}
if (IsRecoilStep(
playerBody.position - lastPosition,
playerBody.position,
enemy.GetComponent<Rigidbody2D>().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<Rigidbody2D>().position);
}
yield return new WaitForFixedUpdate();
Vector2 delta = playerBody.position - lastPosition;
if (IsRecoilStep(
delta,
playerBody.position,
enemy.GetComponent<Rigidbody2D>().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<PlayerHealth>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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<EnemyController> 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<Collider2D>(),
enemy.GetComponent<Collider2D>());
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<Rigidbody2D>();
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<GameObject> hitTargets = new();
Action<CombatHitResult> 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<Collider2D>(),
target.GetComponent<Collider2D>(),
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<PlayerHealth>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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<Collider2D>(),
enemy.GetComponent<Collider2D>());
yield return WaitForReady(enemy);
enemy.transform.localScale = Vector3.one * 0.05f;
Rigidbody2D enemyBody = enemy.GetComponent<Rigidbody2D>();
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<Collider2D>(),
normalCenter,
normalRadii),
Is.False,
"The small target must be outside the normal orbit and its body.");
Assert.That(
ColliderOverlapsEllipse(
enemy.GetComponent<Collider2D>(),
chargedCenter,
chargedRadii),
Is.True,
"The same small target must be inside the charged orbit.");
float healthBefore = enemy.CurrentHealth;
int cycloneHits = 0;
Action<CombatHitResult> 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<PlayerHealth>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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<CombatHitResult> 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<PlayerHealth>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
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<Collider2D>(),
enemy.GetComponent<Collider2D>());
yield return WaitForReady(enemy);
Vector2 targetPosition = playerBody.position + new Vector2(offsetX, 0.5f);
Rigidbody2D enemyBody = enemy.GetComponent<Rigidbody2D>();
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<Collider2D>(),
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<SpawnDirector>();
player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
Assert.That(director, Is.Not.Null);
Assert.That(player, Is.Not.Null);
director.enabled = false;
RunMissionTracker missionTracker =
UnityEngine.Object.FindAnyObjectByType<RunMissionTracker>();
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<Keyboard>();
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<EnemyController>(
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<EnemyController>();
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, "<FacingDirection>k__BackingField", facing);
SetPrivateField(player, "<MoveDirection>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, "<FacingDirection>k__BackingField", direction);
SetPrivateField(player, "<MoveDirection>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<EnemyModel>();
Assert.That(model, Is.Not.Null);
model.ConfigureEventMultipliers(multiplier, 1f, 1f);
}
}
}