Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
using System.Collections;
|
||||
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.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class RangeAuditPlayModeTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private static readonly EnemyKind[] HorizontalRootKinds =
|
||||
{
|
||||
EnemyKind.Skeleton,
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
EnemyKind.GreatswordSkeleton,
|
||||
EnemyKind.Werewolf,
|
||||
EnemyKind.Werebear,
|
||||
EnemyKind.NecroGolem,
|
||||
EnemyKind.Necromancer,
|
||||
};
|
||||
|
||||
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 NecroGolemAttack03_UsesActualPlayerBodyAndActiveEnd()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
PlayerHealth health = player?.GetComponent<PlayerHealth>();
|
||||
Rigidbody2D playerBody = player?.GetComponent<Rigidbody2D>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
Assert.That(health, Is.Not.Null);
|
||||
Assert.That(playerBody, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableSceneEnemies();
|
||||
player.enabled = false;
|
||||
player.GetComponent<BumpCombatResolver>().enabled = false;
|
||||
|
||||
EnemyController golem = SpawnGolem(director, Vector2.zero);
|
||||
yield return null;
|
||||
ConfigureGolemAttack03(golem, director);
|
||||
golem.enabled = false;
|
||||
EnemyAttack attack = golem.GetComponent<EnemyAttack>();
|
||||
Rigidbody2D golemBody = golem.GetComponent<Rigidbody2D>();
|
||||
AssertGolemAttack03Contract(golem);
|
||||
|
||||
Vector2 root = golemBody.position;
|
||||
Vector2 rightOutside = root + new Vector2(1.8f, 1.9f);
|
||||
Vector2 rightInside = root + new Vector2(1.9f, 0.4f);
|
||||
float activeDuration = golem.ActiveDuration;
|
||||
|
||||
PlacePlayer(playerBody, rightOutside);
|
||||
float healthBefore = health.CurrentHealth;
|
||||
BeginActiveAttack(
|
||||
golem,
|
||||
attack,
|
||||
Vector2.right,
|
||||
playerBody.position);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
attack.TickActive(activeDuration * 0.5f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
PlacePlayer(playerBody, rightInside);
|
||||
attack.TickActive(activeDuration);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthBefore),
|
||||
"A contact first observed at the exact active end must not hit.");
|
||||
attack.TickActive(activeDuration + 0.01f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
attack.EndAttack();
|
||||
SetState(golem, EnemyState.Recovery, golem.RecoveryDuration);
|
||||
attack.TickActive(activeDuration + 0.1f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
PlacePlayer(playerBody, rightInside);
|
||||
BeginActiveAttack(
|
||||
golem,
|
||||
attack,
|
||||
Vector2.right,
|
||||
playerBody.position);
|
||||
float healthAfterRightHit = health.CurrentHealth;
|
||||
Assert.That(
|
||||
healthAfterRightHit,
|
||||
Is.LessThan(healthBefore),
|
||||
"Attack03 must damage the actual player body inside the box.");
|
||||
attack.TickActive(activeDuration * 0.8f);
|
||||
attack.TickActive(activeDuration);
|
||||
attack.TickActive(activeDuration + 0.01f);
|
||||
attack.EndAttack();
|
||||
SetState(golem, EnemyState.Recovery, golem.RecoveryDuration);
|
||||
attack.TickActive(activeDuration + 0.1f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthAfterRightHit).Within(0.0001f),
|
||||
"Attack03 must damage once through active end and recovery.");
|
||||
|
||||
yield return new WaitForSecondsRealtime(
|
||||
health.InvulnerabilityDuration + 0.05f);
|
||||
health.Heal(health.MaxHealth);
|
||||
Vector2 leftOutside = root + new Vector2(-1.8f, 1.9f);
|
||||
Vector2 leftInside = root + new Vector2(-1.9f, 0.4f);
|
||||
PlacePlayer(playerBody, leftOutside);
|
||||
float leftBefore = health.CurrentHealth;
|
||||
BeginActiveAttack(
|
||||
golem,
|
||||
attack,
|
||||
Vector2.left,
|
||||
playerBody.position);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(leftBefore));
|
||||
PlacePlayer(playerBody, leftInside);
|
||||
attack.TickActive(activeDuration * 0.8f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.LessThan(leftBefore),
|
||||
"The mirrored left Attack03 box must hit the actual body.");
|
||||
float leftAfterHit = health.CurrentHealth;
|
||||
attack.TickActive(activeDuration);
|
||||
attack.TickActive(activeDuration + 0.01f);
|
||||
attack.EndAttack();
|
||||
SetState(golem, EnemyState.Recovery, golem.RecoveryDuration);
|
||||
attack.TickActive(activeDuration + 0.1f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(leftAfterHit).Within(0.0001f));
|
||||
|
||||
Object.Destroy(golem.gameObject);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator HorizontalRootKinds_LockDiagonalRequestedDirection()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableSceneEnemies();
|
||||
|
||||
for (int i = 0; i < HorizontalRootKinds.Length; i++)
|
||||
{
|
||||
EnemyKind kind = HorizontalRootKinds[i];
|
||||
EnemyController enemy = Object.Instantiate(
|
||||
FindCatalogPrefab(director, kind),
|
||||
new Vector2(20f + i * 2f, 20f),
|
||||
Quaternion.identity);
|
||||
yield return null;
|
||||
enemy.enabled = false;
|
||||
EnemyAttack attack = enemy.GetComponent<EnemyAttack>();
|
||||
Assert.That(attack, Is.Not.Null, kind.ToString());
|
||||
Assert.That(
|
||||
enemy.CurrentAttackPattern.DirectionMode,
|
||||
Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot),
|
||||
kind.ToString());
|
||||
|
||||
attack.BeginWarning(
|
||||
new Vector2(1f, 1f).normalized,
|
||||
enemy.transform.position + Vector3.one,
|
||||
enemy.WarningDuration);
|
||||
Assert.That(
|
||||
attack.LockedDirection,
|
||||
Is.EqualTo(Vector2.right),
|
||||
kind + " must resolve positive diagonal input horizontally.");
|
||||
attack.EndAttack(true);
|
||||
|
||||
attack.BeginWarning(
|
||||
new Vector2(-1f, 1f).normalized,
|
||||
enemy.transform.position + Vector3.one,
|
||||
enemy.WarningDuration);
|
||||
Assert.That(
|
||||
attack.LockedDirection,
|
||||
Is.EqualTo(Vector2.left),
|
||||
kind + " must preserve the mirrored horizontal side.");
|
||||
attack.EndAttack(true);
|
||||
Object.Destroy(enemy.gameObject);
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EnemyGroundAnchors_StayOnSharedPlayerBoundaryAfterSpawnWarningAndKnockback()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableSceneEnemies();
|
||||
|
||||
ArenaBounds bounds = Object.FindAnyObjectByType<ArenaBounds>();
|
||||
EnemyController normalPrefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.Skeleton);
|
||||
EnemyController eventPrefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.GreatswordSkeleton);
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
Assert.That(normalPrefab, Is.Not.Null);
|
||||
Assert.That(eventPrefab, Is.Not.Null);
|
||||
|
||||
Vector2 halfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2[] corners =
|
||||
{
|
||||
new(halfExtents.x, halfExtents.y),
|
||||
new(-halfExtents.x, -halfExtents.y),
|
||||
};
|
||||
for (int i = 0; i < corners.Length; i++)
|
||||
{
|
||||
EnemyController enemy = Object.Instantiate(
|
||||
normalPrefab,
|
||||
corners[i],
|
||||
Quaternion.identity);
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, enemy, "normal spawn");
|
||||
|
||||
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
|
||||
body.position = corners[i];
|
||||
Physics2D.SyncTransforms();
|
||||
SetState(enemy, EnemyState.Warning, 5f);
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, enemy, "warning correction");
|
||||
|
||||
body.position = corners[i];
|
||||
Physics2D.SyncTransforms();
|
||||
bool damaged = enemy.TryTakeDamage(
|
||||
1f,
|
||||
corners[i].normalized,
|
||||
2f);
|
||||
Assert.That(damaged, Is.True, "a valid nonlethal knockback hit must be accepted");
|
||||
yield return new WaitForFixedUpdate();
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, enemy, "knockback correction");
|
||||
Object.Destroy(enemy.gameObject);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
EnemyController eventEnemy = Object.Instantiate(
|
||||
eventPrefab,
|
||||
corners[0],
|
||||
Quaternion.identity);
|
||||
eventEnemy.ConfigureRunEventEnemy(
|
||||
RunTimedEvent.MidBoss,
|
||||
director.GetEventEnemyTuning(RunTimedEvent.MidBoss));
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, eventEnemy, "scaled event spawn");
|
||||
Object.Destroy(eventEnemy.gameObject);
|
||||
}
|
||||
|
||||
private static void AssertEnemyBodyInside(
|
||||
ArenaBounds bounds,
|
||||
EnemyController enemy,
|
||||
string checkpoint)
|
||||
{
|
||||
Collider2D bodyCollider = enemy.GetComponent<Collider2D>();
|
||||
Assert.That(bodyCollider, Is.Not.Null, checkpoint);
|
||||
Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2 groundAnchor = enemy.GroundAnchorPosition;
|
||||
Assert.That(
|
||||
groundAnchor.x,
|
||||
Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.001f),
|
||||
checkpoint);
|
||||
Assert.That(
|
||||
groundAnchor.x,
|
||||
Is.LessThanOrEqualTo(safeHalfExtents.x + 0.001f),
|
||||
checkpoint);
|
||||
Assert.That(
|
||||
groundAnchor.y,
|
||||
Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.001f),
|
||||
checkpoint);
|
||||
Assert.That(
|
||||
groundAnchor.y,
|
||||
Is.LessThanOrEqualTo(safeHalfExtents.y + 0.001f),
|
||||
checkpoint);
|
||||
}
|
||||
|
||||
private static void AssertGolemAttack03Contract(EnemyController golem)
|
||||
{
|
||||
EnemyAttackPattern pattern = golem.CurrentAttackPattern;
|
||||
Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box));
|
||||
Assert.That(pattern.AttackLength, Is.EqualTo(1.05f).Within(0.0001f));
|
||||
Assert.That(pattern.AttackWidth, Is.EqualTo(0.55f).Within(0.0001f));
|
||||
Assert.That(
|
||||
pattern.AttackOriginOffset,
|
||||
Is.EqualTo(new Vector2(0.05f, -0.05f)));
|
||||
Assert.That(
|
||||
pattern.AnimationLeadTime,
|
||||
Is.EqualTo(0.625f).Within(0.0001f));
|
||||
Assert.That(golem.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
Assert.That(
|
||||
EnemyAttack.GetRootScale(golem.transform),
|
||||
Is.EqualTo(2f).Within(0.001f));
|
||||
Assert.That(golem.AttackLength, Is.EqualTo(2.1f).Within(0.001f));
|
||||
Assert.That(golem.AttackWidth, Is.EqualTo(1.1f).Within(0.001f));
|
||||
}
|
||||
|
||||
private static void BeginActiveAttack(
|
||||
EnemyController enemy,
|
||||
EnemyAttack attack,
|
||||
Vector2 direction,
|
||||
Vector2 target)
|
||||
{
|
||||
attack.BeginWarning(direction, target, enemy.WarningDuration);
|
||||
SetState(enemy, EnemyState.Warning, enemy.WarningDuration);
|
||||
attack.Activate();
|
||||
SetState(enemy, EnemyState.Active, enemy.ActiveDuration);
|
||||
}
|
||||
|
||||
private static EnemyController SpawnGolem(
|
||||
SpawnDirector director,
|
||||
Vector2 position)
|
||||
{
|
||||
EnemyController prefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.NecroGolem);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
return Object.Instantiate(prefab, position, Quaternion.identity);
|
||||
}
|
||||
|
||||
private static void ConfigureGolemAttack03(
|
||||
EnemyController golem,
|
||||
SpawnDirector director)
|
||||
{
|
||||
golem.ConfigureRunEventEnemy(
|
||||
RunTimedEvent.MidBoss,
|
||||
director.GetEventEnemyTuning(RunTimedEvent.MidBoss));
|
||||
SetPrivateField(golem, "attackPatternIndex", 2);
|
||||
}
|
||||
|
||||
private static void PlacePlayer(
|
||||
Rigidbody2D playerBody,
|
||||
Vector2 position)
|
||||
{
|
||||
playerBody.linearVelocity = Vector2.zero;
|
||||
playerBody.position = position;
|
||||
Physics2D.SyncTransforms();
|
||||
}
|
||||
|
||||
private static void SetState(
|
||||
EnemyController enemy,
|
||||
EnemyState state,
|
||||
float duration)
|
||||
{
|
||||
typeof(EnemyController)
|
||||
.GetMethod("EnterState", NonPublicInstance)
|
||||
?.Invoke(enemy, new object[] { state, duration });
|
||||
}
|
||||
|
||||
private static void SetPrivateField<T>(
|
||||
object target,
|
||||
string fieldName,
|
||||
T value)
|
||||
{
|
||||
target.GetType()
|
||||
.GetField(fieldName, NonPublicInstance)
|
||||
?.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static void DisableSceneEnemies()
|
||||
{
|
||||
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None);
|
||||
foreach (EnemyController enemy in enemies)
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
Assert.That(Time.timeScale, Is.EqualTo(1f));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user