Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
@@ -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>"}.";
}
}
}