Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
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>"}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user