1440 lines
56 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|