666 lines
28 KiB
C#
666 lines
28 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using BumpCombat.Core;
|
|
using BumpCombat.Enemies;
|
|
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 NecromancerSummonPlayModeRegressionTests
|
|
{
|
|
private const float ExpectedSummonSpawnRadius = 2.5f;
|
|
private const float ExpectedSummonSpawnMinimumSeparation = 1.5f;
|
|
private const float ExpectedSummonPlayerClearance = 1.25f;
|
|
private static readonly BindingFlags NonPublicInstance =
|
|
BindingFlags.Instance | BindingFlags.NonPublic;
|
|
private bool previousGrantCatalogForTests;
|
|
private bool previousProductionModeForTests;
|
|
private float previousTimeScale;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
|
|
previousProductionModeForTests = RunManager.ForceProductionModeForTests;
|
|
previousTimeScale = Time.timeScale;
|
|
ArtifactRewardController.GrantCatalogForTests = true;
|
|
RunManager.ForceProductionModeForTests = false;
|
|
Time.timeScale = 1f;
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests;
|
|
RunManager.ForceProductionModeForTests = previousProductionModeForTests;
|
|
Time.timeScale = previousTimeScale;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator AmbientSummonsWaitForWarningActiveAndRecoveryToFinish()
|
|
{
|
|
yield return LoadCombatScene();
|
|
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
|
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
|
Assert.That(director, Is.Not.Null);
|
|
Assert.That(player, Is.Not.Null);
|
|
director.enabled = false;
|
|
player.GetComponent<PlayerHealth>().GrantInvulnerability(60f);
|
|
|
|
EnemyController boss = UnityEngine.Object.Instantiate(
|
|
FindCatalogPrefab(director, EnemyKind.Necromancer),
|
|
player.transform.position + Vector3.right * 7f,
|
|
Quaternion.identity);
|
|
yield return WaitForReady(boss);
|
|
NecromancerBossController necromancer =
|
|
boss.GetComponent<NecromancerBossController>();
|
|
Assert.That(necromancer, Is.Not.Null);
|
|
|
|
float generalDueTime = Time.time - 0.1f;
|
|
float crowdDueTime = Time.time - 0.1f;
|
|
SetPrivateField(necromancer, "nextGeneralSummonTime", generalDueTime);
|
|
SetPrivateField(necromancer, "nextCrowdSummonTime", crowdDueTime);
|
|
MethodInfo enterState = typeof(EnemyController).GetMethod(
|
|
"EnterState",
|
|
NonPublicInstance);
|
|
MethodInfo update = typeof(NecromancerBossController).GetMethod(
|
|
"Update",
|
|
NonPublicInstance);
|
|
Assert.That(enterState, Is.Not.Null);
|
|
Assert.That(update, Is.Not.Null);
|
|
|
|
foreach (EnemyState attackState in new[]
|
|
{
|
|
EnemyState.Warning,
|
|
EnemyState.Active,
|
|
EnemyState.Recovery,
|
|
})
|
|
{
|
|
enterState.Invoke(boss, new object[] { attackState, 5f });
|
|
Assert.That(boss.IsAttackSequenceInProgress, Is.True);
|
|
|
|
update.Invoke(necromancer, null);
|
|
|
|
Assert.That(boss.State, Is.EqualTo(attackState));
|
|
Assert.That(GetPrivateFloat(necromancer, "nextGeneralSummonTime"),
|
|
Is.EqualTo(generalDueTime));
|
|
Assert.That(GetPrivateFloat(necromancer, "nextCrowdSummonTime"),
|
|
Is.EqualTo(crowdDueTime));
|
|
Assert.That(CountOwnedSummons(boss), Is.Zero);
|
|
yield return null;
|
|
}
|
|
|
|
enterState.Invoke(boss, new object[] { EnemyState.Chase, 0f });
|
|
update.Invoke(necromancer, null);
|
|
Assert.That(CountOwnedSummons(boss), Is.EqualTo(1),
|
|
"A due general summon should start once the attack sequence is safe.");
|
|
float firstSummonStartedAt = Time.time;
|
|
Assert.That(GetPrivateFloat(necromancer, "nextGeneralSummonTime"),
|
|
Is.GreaterThan(Time.time));
|
|
Assert.That(GetPrivateFloat(necromancer, "nextCrowdSummonTime"),
|
|
Is.LessThanOrEqualTo(Time.time),
|
|
"The other due timer must remain pending after a successful summon.");
|
|
Assert.That(boss.GetSummonAnimationDuration(),
|
|
Is.EqualTo(1.25f).Within(0.005f));
|
|
Assert.That(GetPrivateFloat(necromancer, "summonLockUntil") - firstSummonStartedAt,
|
|
Is.EqualTo(1.25f).Within(0.03f),
|
|
"The caster's movement lock must cover the full Summon clip.");
|
|
|
|
update.Invoke(necromancer, null);
|
|
Assert.That(CountOwnedSummons(boss), Is.EqualTo(1),
|
|
"A single Update must not start both due ambient summon types.");
|
|
|
|
yield return new WaitForSeconds(1f);
|
|
Assert.That(CountOwnedSummons(boss), Is.EqualTo(1),
|
|
"The due crowd summon must stay pending during the 1.25-second caster clip.");
|
|
Assert.That(boss.GetComponent<Animator>()
|
|
.GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
|
Is.True,
|
|
"The caster must still show its Summon animation at one second.");
|
|
|
|
float secondSummonDeadline = Time.realtimeSinceStartup + 3f;
|
|
while (Time.realtimeSinceStartup < secondSummonDeadline
|
|
&& CountOwnedSummons(boss) < 4)
|
|
{
|
|
yield return null;
|
|
}
|
|
|
|
Assert.That(CountOwnedSummons(boss), Is.EqualTo(4),
|
|
"The deferred crowd summon should start after the first summon lock ends.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SummonedRiseClipsReachTheirLastSpriteBeforeChase()
|
|
{
|
|
yield return LoadCombatScene();
|
|
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
|
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
|
Assert.That(director, Is.Not.Null);
|
|
Assert.That(player, Is.Not.Null);
|
|
director.enabled = false;
|
|
player.GetComponent<PlayerHealth>().GrantInvulnerability(60f);
|
|
|
|
EnemyKind[] kinds =
|
|
{
|
|
EnemyKind.Bat,
|
|
EnemyKind.Slime,
|
|
EnemyKind.Skeleton,
|
|
EnemyKind.GreatswordSkeleton,
|
|
EnemyKind.ArmoredSkeleton,
|
|
EnemyKind.SkeletonArcher,
|
|
EnemyKind.Necrofire,
|
|
EnemyKind.Werewolf,
|
|
EnemyKind.Werebear,
|
|
EnemyKind.NecroGolem,
|
|
};
|
|
EnemyController[] summoned = new EnemyController[kinds.Length];
|
|
Animator[] animators = new Animator[kinds.Length];
|
|
SpriteRenderer[] renderers = new SpriteRenderer[kinds.Length];
|
|
AnimationClip[] summonClips = new AnimationClip[kinds.Length];
|
|
float[] expectedDurations = new float[kinds.Length];
|
|
Sprite[] expectedFirstSprites = new Sprite[kinds.Length];
|
|
Sprite[] expectedFinalSprites = new Sprite[kinds.Length];
|
|
bool[] sawFinalSpriteDuringSpawn = new bool[kinds.Length];
|
|
|
|
for (int i = 0; i < kinds.Length; i++)
|
|
{
|
|
EnemyController prefab = FindCatalogPrefab(director, kinds[i]);
|
|
Animator prefabAnimator = prefab.GetComponent<Animator>();
|
|
AnimationClip summonClip = FindSummonClip(prefabAnimator);
|
|
Assert.That(summonClip, Is.Not.Null,
|
|
$"{kinds[i]} must have a dedicated Summon clip.");
|
|
expectedDurations[i] = ExpectedSummonDuration(kinds[i]);
|
|
Assert.That(summonClip.length,
|
|
Is.EqualTo(expectedDurations[i]).Within(0.005f));
|
|
summonClips[i] = summonClip;
|
|
|
|
expectedFirstSprites[i] = SampleSprite(summonClip, 0f);
|
|
expectedFinalSprites[i] = SampleSprite(
|
|
summonClip,
|
|
summonClip.length - 0.001f);
|
|
|
|
summoned[i] = UnityEngine.Object.Instantiate(
|
|
prefab,
|
|
player.transform.position + new Vector3(1.5f + i, 2f, 0f),
|
|
Quaternion.identity);
|
|
summoned[i].ConfigureSummonedEnemy(
|
|
null,
|
|
CreateNeutralTuning(prefab),
|
|
i % 2 == 0);
|
|
animators[i] = summoned[i].GetComponent<Animator>();
|
|
renderers[i] = summoned[i].GetComponent<SpriteRenderer>();
|
|
}
|
|
|
|
yield return null;
|
|
yield return null;
|
|
|
|
for (int i = 0; i < summoned.Length; i++)
|
|
{
|
|
Assert.That(summoned[i].MaximumHealth, Is.GreaterThan(0f));
|
|
Assert.That(summoned[i].State, Is.EqualTo(EnemyState.Spawn));
|
|
Assert.That(summoned[i].IsPhaseSummon, Is.EqualTo(i % 2 == 0));
|
|
Assert.That(summoned[i].StateDuration,
|
|
Is.EqualTo(expectedDurations[i]).Within(0.005f));
|
|
Assert.That(summoned[i].GetSummonAnimationDuration(),
|
|
Is.EqualTo(expectedDurations[i]).Within(0.005f));
|
|
Assert.That(animators[i].GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
|
Is.True,
|
|
DescribeAnimator(kinds[i], animators[i], renderers[i],
|
|
"did not enter its rise animation"));
|
|
}
|
|
|
|
yield return new WaitForSeconds(0.3f);
|
|
for (int i = 0; i < summoned.Length; i++)
|
|
{
|
|
Assert.That(summoned[i].State, Is.EqualTo(EnemyState.Spawn));
|
|
Assert.That(animators[i].GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
|
Is.True,
|
|
DescribeAnimator(kinds[i], animators[i], renderers[i],
|
|
"left the rise animation before its clip completed"));
|
|
AnimatorStateInfo state = animators[i].GetCurrentAnimatorStateInfo(0);
|
|
float elapsed = Mathf.Min(
|
|
state.normalizedTime * summonClips[i].length,
|
|
summonClips[i].length - 0.001f);
|
|
Sprite expectedCurrentSprite = SampleSprite(summonClips[i], elapsed);
|
|
Assert.That(renderers[i].sprite, Is.SameAs(expectedCurrentSprite),
|
|
$"{kinds[i]} sprite did not match the sampled rise frame.");
|
|
Assert.That(renderers[i].sprite, Is.Not.SameAs(expectedFirstSprites[i]),
|
|
$"{kinds[i]} did not advance beyond the first rise frame.");
|
|
Assert.That(renderers[i].sprite, Is.Not.SameAs(expectedFinalSprites[i]),
|
|
$"{kinds[i]} must keep rising until the clip completes.");
|
|
}
|
|
|
|
float finalFrameDeadline = Time.realtimeSinceStartup + 1.5f;
|
|
while (Time.realtimeSinceStartup < finalFrameDeadline
|
|
&& !AllTrue(sawFinalSpriteDuringSpawn))
|
|
{
|
|
for (int i = 0; i < summoned.Length; i++)
|
|
{
|
|
if (summoned[i].State == EnemyState.Spawn
|
|
&& renderers[i].sprite == expectedFinalSprites[i])
|
|
{
|
|
sawFinalSpriteDuringSpawn[i] = true;
|
|
}
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
for (int i = 0; i < summoned.Length; i++)
|
|
{
|
|
Assert.That(sawFinalSpriteDuringSpawn[i], Is.True,
|
|
$"{kinds[i]} must display its final rise sprite before Chase.");
|
|
}
|
|
|
|
float chaseDeadline = Time.realtimeSinceStartup + 1f;
|
|
while (Time.realtimeSinceStartup < chaseDeadline
|
|
&& Array.Exists(summoned, enemy => enemy.State == EnemyState.Spawn))
|
|
{
|
|
yield return null;
|
|
}
|
|
|
|
for (int i = 0; i < summoned.Length; i++)
|
|
{
|
|
Assert.That(summoned[i].State, Is.Not.EqualTo(EnemyState.Spawn),
|
|
$"{kinds[i]} should resume normal AI only after the rise clip.");
|
|
UnityEngine.Object.Destroy(summoned[i].gameObject);
|
|
}
|
|
|
|
EnemyController necrofirePrefab = FindCatalogPrefab(
|
|
director,
|
|
EnemyKind.Necrofire);
|
|
EnemyController necrofireSummon = UnityEngine.Object.Instantiate(
|
|
necrofirePrefab,
|
|
player.transform.position + Vector3.left * 7f,
|
|
Quaternion.identity);
|
|
necrofireSummon.ConfigureSummonedEnemy(
|
|
null,
|
|
CreateNeutralTuning(necrofirePrefab),
|
|
false);
|
|
yield return null;
|
|
yield return null;
|
|
Assert.That(necrofireSummon.GetSummonAnimationDuration(),
|
|
Is.EqualTo(0.75f).Within(0.005f));
|
|
Assert.That(necrofireSummon.State, Is.EqualTo(EnemyState.Spawn));
|
|
Assert.That(necrofireSummon.StateDuration, Is.EqualTo(0.75f).Within(0.005f));
|
|
Assert.That(necrofireSummon.GetComponent<Animator>()
|
|
.GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
|
Is.True,
|
|
"Necrofire summons must use their authored rise animation.");
|
|
|
|
float summonDeadline = Time.realtimeSinceStartup + 1f;
|
|
while (Time.realtimeSinceStartup < summonDeadline
|
|
&& necrofireSummon.State == EnemyState.Spawn)
|
|
{
|
|
yield return null;
|
|
}
|
|
|
|
Assert.That(necrofireSummon.State, Is.Not.EqualTo(EnemyState.Spawn));
|
|
|
|
EnemyController waveEnemy = UnityEngine.Object.Instantiate(
|
|
necrofirePrefab,
|
|
player.transform.position + Vector3.left * 8f,
|
|
Quaternion.identity);
|
|
yield return null;
|
|
yield return null;
|
|
Assert.That(waveEnemy.GetSummonAnimationDuration(),
|
|
Is.EqualTo(0.75f).Within(0.005f),
|
|
"The prefab may provide a Summon clip without playing it for a normal wave spawn.");
|
|
Assert.That(waveEnemy.State, Is.EqualTo(EnemyState.Spawn));
|
|
Assert.That(waveEnemy.StateDuration, Is.EqualTo(0.15f).Within(0.005f),
|
|
"Non-summoned wave enemies keep the existing short Spawn timing.");
|
|
Assert.That(waveEnemy.GetComponent<Animator>()
|
|
.GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
|
Is.False,
|
|
"Normal wave spawns must not play the necromancer rise animation.");
|
|
UnityEngine.Object.Destroy(necrofireSummon.gameObject);
|
|
UnityEngine.Object.Destroy(waveEnemy.gameObject);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator AmbientAndProtectedSummonsRiseNearOwnerAtArenaEdges()
|
|
{
|
|
yield return LoadCombatScene();
|
|
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
|
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
|
ArenaBounds bounds = ArenaBounds.Resolve();
|
|
Assert.That(director, Is.Not.Null);
|
|
Assert.That(player, Is.Not.Null);
|
|
Assert.That(bounds, Is.Not.Null);
|
|
director.enabled = false;
|
|
player.GetComponent<PlayerHealth>().GrantInvulnerability(60f);
|
|
int initialAliveCount = EnemyController.AliveCount;
|
|
|
|
EnemyController necromancerPrefab = FindCatalogPrefab(
|
|
director,
|
|
EnemyKind.Necromancer);
|
|
Vector2 halfExtents = director.SpawnReachableHalfExtents;
|
|
EnemyController edgeOwner = UnityEngine.Object.Instantiate(
|
|
necromancerPrefab,
|
|
Vector2.zero,
|
|
Quaternion.identity);
|
|
yield return WaitForReady(edgeOwner);
|
|
Vector2 edgePosition = new(halfExtents.x, 0f);
|
|
Assert.That(edgeOwner.TryReposition(edgePosition), Is.True);
|
|
|
|
Assert.That(director.TrySpawnAmbientSummons(
|
|
edgeOwner,
|
|
3,
|
|
true,
|
|
out List<EnemyController> ambientSummons), Is.True);
|
|
AssertSummonsAreNearOwner(
|
|
edgeOwner,
|
|
ambientSummons,
|
|
bounds,
|
|
player,
|
|
ExpectedSummonSpawnMinimumSeparation);
|
|
|
|
EnemyController cornerOwner = UnityEngine.Object.Instantiate(
|
|
necromancerPrefab,
|
|
Vector2.zero,
|
|
Quaternion.identity);
|
|
yield return WaitForReady(cornerOwner);
|
|
Vector2 cornerPosition = new(-halfExtents.x, halfExtents.y);
|
|
Assert.That(cornerOwner.TryReposition(cornerPosition), Is.True);
|
|
|
|
SetPrivateField(
|
|
director,
|
|
"maximumAliveEnemies",
|
|
EnemyController.AliveCount + 3);
|
|
Assert.That(director.TrySpawnPhaseSummons(
|
|
cornerOwner,
|
|
2,
|
|
true,
|
|
out List<EnemyController> phaseSummons), Is.True);
|
|
AssertSummonsAreNearOwner(
|
|
cornerOwner,
|
|
phaseSummons,
|
|
bounds,
|
|
player,
|
|
0.25f);
|
|
for (int i = 0; i < phaseSummons.Count; i++)
|
|
{
|
|
for (int j = 0; j < ambientSummons.Count; j++)
|
|
{
|
|
Assert.That(Vector2.Distance(
|
|
phaseSummons[i].transform.position,
|
|
ambientSummons[j].transform.position),
|
|
Is.GreaterThanOrEqualTo(0.25f - 0.001f),
|
|
"A later summon must avoid the same position as an existing summon.");
|
|
}
|
|
}
|
|
|
|
Assert.That(EnemyController.AliveCount, Is.EqualTo(initialAliveCount + 8),
|
|
"The two casters and six summons must fit under the boss encounter cap.");
|
|
SetPrivateField(director, "maximumAliveEnemies", 100);
|
|
Assert.That(director.TrySpawnAmbientSummons(
|
|
cornerOwner,
|
|
1,
|
|
true,
|
|
out List<EnemyController> rejectedAmbientSummons), Is.False,
|
|
"Ambient summons must stop at the boss encounter cap.");
|
|
Assert.That(rejectedAmbientSummons, Is.Empty);
|
|
SetPrivateField(director, "maximumAliveEnemies", EnemyController.AliveCount);
|
|
Assert.That(director.TrySpawnPhaseSummons(
|
|
cornerOwner,
|
|
2,
|
|
true,
|
|
out List<EnemyController> rejectedSummons), Is.False,
|
|
"Protected summons must remain blocked at the global hard cap.");
|
|
Assert.That(rejectedSummons, Is.Empty);
|
|
Assert.That(ambientSummons.TrueForAll(enemy => enemy != null && !enemy.IsDead), Is.True);
|
|
Assert.That(phaseSummons.TrueForAll(enemy => enemy != null && !enemy.IsDead), Is.True);
|
|
}
|
|
|
|
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 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 AnimationClip FindSummonClip(Animator animator)
|
|
{
|
|
if (animator == null || animator.runtimeAnimatorController == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
AnimationClip[] clips = animator.runtimeAnimatorController.animationClips;
|
|
for (int i = 0; i < clips.Length; i++)
|
|
{
|
|
if (clips[i] != null
|
|
&& clips[i].name.IndexOf("Summon", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return clips[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static Sprite SampleSprite(AnimationClip clip, float time)
|
|
{
|
|
#if UNITY_EDITOR
|
|
// Read the authored expectation independently of runtime Animator evaluation.
|
|
// Sampling an unbound preview object during PlayMode can leave its sprite null.
|
|
foreach (var binding in UnityEditor.AnimationUtility.GetObjectReferenceCurveBindings(clip))
|
|
{
|
|
if (binding.type != typeof(SpriteRenderer) || binding.propertyName != "m_Sprite")
|
|
continue;
|
|
Sprite expected = null;
|
|
foreach (var key in UnityEditor.AnimationUtility.GetObjectReferenceCurve(clip, binding))
|
|
if (key.time <= time) expected = key.value as Sprite;
|
|
Assert.That(expected, Is.Not.Null, "The authored summon frame must resolve.");
|
|
return expected;
|
|
}
|
|
Assert.Fail("The summon clip has no SpriteRenderer curve.");
|
|
#else
|
|
Assert.Ignore("Authored sprite-curve verification requires the Unity Editor.");
|
|
#endif
|
|
return null;
|
|
}
|
|
|
|
private static float ExpectedSummonDuration(EnemyKind kind)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case EnemyKind.Bat:
|
|
case EnemyKind.Slime:
|
|
case EnemyKind.Werewolf:
|
|
case EnemyKind.Werebear:
|
|
return 0.5f;
|
|
case EnemyKind.Necrofire:
|
|
case EnemyKind.NecroGolem:
|
|
return 0.75f;
|
|
default:
|
|
return 0.625f;
|
|
}
|
|
}
|
|
|
|
private static void AssertSummonsAreNearOwner(
|
|
EnemyController owner,
|
|
IReadOnlyList<EnemyController> summons,
|
|
ArenaBounds bounds,
|
|
PlayerController player,
|
|
float minimumGroupSpacing)
|
|
{
|
|
Assert.That(summons.Count, Is.EqualTo(3));
|
|
for (int i = 0; i < summons.Count; i++)
|
|
{
|
|
EnemyController summon = summons[i];
|
|
Vector2 position = summon.transform.position;
|
|
Assert.That(summon.IsSummoned, Is.True);
|
|
Assert.That(summon.SummonOwner, Is.SameAs(owner));
|
|
Assert.That(Vector2.Distance(owner.transform.position, position),
|
|
Is.LessThanOrEqualTo(ExpectedSummonSpawnRadius + 0.001f),
|
|
"A Necromancer summon should appear on the nearby ring around its owner.");
|
|
Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True,
|
|
"A nearby summon must remain on the reachable arena floor.");
|
|
Assert.That(Vector2.Distance(position, player.transform.position),
|
|
Is.GreaterThanOrEqualTo(ExpectedSummonPlayerClearance - 0.001f),
|
|
"A nearby summon must not overlap the player.");
|
|
|
|
for (int j = 0; j < i; j++)
|
|
{
|
|
Assert.That(Vector2.Distance(position, summons[j].transform.position),
|
|
Is.GreaterThanOrEqualTo(minimumGroupSpacing - 0.001f),
|
|
"A summon group must use distinct nearby positions.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static RunEventEnemyTuning CreateNeutralTuning(EnemyController prefab)
|
|
{
|
|
return RunEventEnemyTuning.Create(
|
|
prefab.Definition.Kind,
|
|
1f,
|
|
prefab.Definition.ExperienceValue,
|
|
1f,
|
|
Color.white,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
1,
|
|
1f,
|
|
1f,
|
|
1,
|
|
1f,
|
|
true);
|
|
}
|
|
|
|
private static void SetPrivateField<T>(
|
|
object target,
|
|
string fieldName,
|
|
T value)
|
|
{
|
|
FieldInfo field = target.GetType().GetField(
|
|
fieldName,
|
|
NonPublicInstance);
|
|
Assert.That(field, Is.Not.Null, $"Could not find field {fieldName}.");
|
|
field.SetValue(target, value);
|
|
}
|
|
|
|
private static float GetPrivateFloat(object target, string fieldName)
|
|
{
|
|
FieldInfo field = target.GetType().GetField(
|
|
fieldName,
|
|
NonPublicInstance);
|
|
Assert.That(field, Is.Not.Null, $"Could not find field {fieldName}.");
|
|
return (float)field.GetValue(target);
|
|
}
|
|
|
|
private static int CountOwnedSummons(EnemyController owner)
|
|
{
|
|
int count = 0;
|
|
EnemyController[] enemies = UnityEngine.Object.FindObjectsByType<EnemyController>(
|
|
FindObjectsSortMode.None);
|
|
for (int i = 0; i < enemies.Length; i++)
|
|
{
|
|
if (enemies[i].IsSummoned && enemies[i].SummonOwner == owner)
|
|
{
|
|
count++;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static bool AllTrue(bool[] values)
|
|
{
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
if (!values[i])
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string DescribeAnimator(
|
|
EnemyKind kind,
|
|
Animator animator,
|
|
SpriteRenderer renderer,
|
|
string reason)
|
|
{
|
|
AnimatorStateInfo state = animator.GetCurrentAnimatorStateInfo(0);
|
|
return $"{kind} {reason}: state={state.fullPathHash}, "
|
|
+ $"normalizedTime={state.normalizedTime:0.000}, "
|
|
+ $"sprite={renderer.sprite?.name ?? "<null>"}.";
|
|
}
|
|
}
|
|
}
|