2.5d test

This commit is contained in:
2026-09-27 21:08:05 +09:00
parent 84dd12414a
commit abd2da2057
201 changed files with 24297 additions and 295 deletions
@@ -0,0 +1,65 @@
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class Defense25DComparisonTests
{
[Test]
public void IndependentSceneExists()
{
Assert.That(AssetDatabase.LoadAssetAtPath<SceneAsset>(
"Assets/_Project/Scenes/Defense25DComparison.unity"), Is.Not.Null);
}
[TestCase(false, .85f, 38.0625f / 32f, 15.03125f / 32f, 3)]
[TestCase(true, 1.25f, 46f / 32f, 18f / 32f, 5)]
public void CyclonePreservesAuthoredEllipseAndTickSchedule(
bool charged, float range, float radiusX, float radiusZ, int count)
{
Vector2 radii = CycloneGeometry.Radii(range, charged);
Assert.That(radii.x, Is.EqualTo(radiusX).Within(.00001f));
Assert.That(radii.y, Is.EqualTo(radiusZ).Within(.00001f));
Assert.That(CycloneGeometry.ScheduledHitCount(charged ? .65f : .36f, .14f), Is.EqualTo(count));
MethodInfo original = typeof(ActiveArtifactController).GetMethod(
"GetCycloneHitRadii", BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(original.Invoke(null, new object[] { range, charged }), Is.EqualTo(radii));
}
[Test]
public void EllipseUsesBodyEdgeRatherThanGroundCenterOrSphere()
{
Vector2 radii = CycloneGeometry.Radii(.85f, false);
Vector2 bodyCenter = new(0f, radii.y + .2f);
Vector2 Closest(Vector2 point) => bodyCenter + Vector2.ClampMagnitude(point - bodyCenter, .24f);
Assert.That(CycloneGeometry.Overlaps(Vector2.zero, radii, Closest), Is.True);
bodyCenter.y = radii.y + .3f;
Assert.That(CycloneGeometry.Overlaps(Vector2.zero, radii, Closest), Is.False);
}
[Test]
public void ViewportCentersIntegerUpscaleAndRejectsLetterbox()
{
Rect rect = Defense25DView.GetViewport(1920, 1080);
Assert.That(rect, Is.EqualTo(new Rect(0, 0, 1920, 1080)));
rect = Defense25DView.GetViewport(1399, 799);
Assert.That(rect.width, Is.EqualTo(1280));
Assert.That(rect.height, Is.EqualTo(720));
Assert.That(rect.Contains(Vector2.zero), Is.False);
}
[Test]
public void SharedBumpDamageStillFloorsOnlyAtFinalBoundary()
{
Assert.That(DamageCalculator.FinalizeDamage(
BumpCombatMath.ApplySideMultiplier(10.9f, HitSide.Side)), Is.EqualTo(13));
Assert.That(DamageCalculator.FinalizeDamage(
BumpCombatMath.ApplySideMultiplier(10f, HitSide.Back)), Is.EqualTo(15));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2b42d4c49df64f999778421ebe6a83e3
@@ -0,0 +1,55 @@
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class Defense25DSkeletonCombatTests
{
[Test]
public void SkeletonSlashUsesSourceBoxFootprintAndRejectsTargetsBehindOrigin()
{
Assert.That(Defense25DEnemy.SkeletonSlashIntersectsCircle(Vector2.zero, Vector2.right, 1f, .75f,
new Vector2(.5f, .49f), .1f), Is.False,
"A hurtbox beyond the authored half-width must not be hit.");
Assert.That(Defense25DEnemy.SkeletonSlashIntersectsCircle(Vector2.zero, Vector2.right, 1f, .75f,
new Vector2(.5f, .45f), .1f), Is.True,
"A circular hurtbox overlapping the authored box edge must be hit.");
Assert.That(Defense25DEnemy.SkeletonSlashIntersectsCircle(Vector2.zero, Vector2.right, 1f, .75f,
new Vector2(-.01f, 0f), .5f), Is.False,
"The source attack rejects a target whose collider center is behind the slash origin.");
}
[Test]
public void SkeletonAttackAnimationUsesDefinitionLeadTime()
{
EnemyDefinition skeleton = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Skeleton.asset");
Assert.That(skeleton, Is.Not.Null);
EnemyAttackPattern pattern = skeleton.GetAttackPattern(0);
Assert.That(Defense25DEnemy.SkeletonAttackAnimationShouldStart(pattern, .324f), Is.False);
Assert.That(Defense25DEnemy.SkeletonAttackAnimationShouldStart(pattern, .326f), Is.True);
Assert.That(pattern.WarningDuration, Is.EqualTo(.7f).Within(.0001f));
Assert.That(pattern.AnimationLeadTime, Is.EqualTo(.375f).Within(.0001f));
}
[Test]
public void SkeletonAttackFramesFollowTheNonLoopingSourceClip()
{
AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
"Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim");
Assert.That(clip, Is.Not.Null);
Assert.That(clip.frameRate, Is.EqualTo(8f).Within(.001f));
Assert.That(clip.length, Is.EqualTo(.75f).Within(.001f));
Assert.That(clip.isLooping, Is.False);
Assert.That(Defense25DEnemy.SkeletonAttackFrameAt(clip, .124f, 6), Is.EqualTo(0));
Assert.That(Defense25DEnemy.SkeletonAttackFrameAt(clip, .126f, 6), Is.EqualTo(1));
Assert.That(Defense25DEnemy.SkeletonAttackFrameAt(clip, 1f, 6), Is.EqualTo(5),
"A non-looping source attack holds its last authored frame through recovery.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 799325f1d64440fd8f07e757025dff0e
@@ -3,6 +3,7 @@
"rootNamespace": "BumpCombat.PlayModeTests",
"references": [
"BumpCombat.Runtime",
"Unity.RenderPipelines.Universal.Runtime",
"Unity.InputSystem",
"UnityEngine.UI"
],
@@ -0,0 +1,222 @@
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Rendering.Universal;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class ComparisonSpriteFlipRenderPlayModeTests
{
private const int FlipRenderTestLayer = 31;
[UnityTest]
public IEnumerator SpriteRendererFlipXMirrorsAsymmetricRenderedSprite()
{
const int width = 80;
const int height = 40;
const int textureWidth = 8;
const int textureHeight = 4;
GameObject cameraObject = null;
GameObject spriteObject = null;
Texture2D source = null;
Texture2D capture = null;
RenderTexture target = null;
Material material = null;
Sprite sprite = null;
try
{
Shader shader = Shader.Find("BumpCombat/Comparison Sprite Depth");
Assert.That(shader, Is.Not.Null);
material = new Material(shader) { enableInstancing = true };
source = new Texture2D(textureWidth, textureHeight, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp,
};
Color32[] sourcePixels = new Color32[textureWidth * textureHeight];
for (int i = 0; i < sourcePixels.Length; i++) sourcePixels[i] = new Color32(0, 0, 0, 0);
sourcePixels[0] = new Color32(255, 32, 32, 255);
sourcePixels[1] = new Color32(255, 96, 32, 255);
sourcePixels[textureWidth + 2] = new Color32(32, 255, 32, 255);
sourcePixels[textureWidth * 2 + 5] = new Color32(32, 64, 255, 255);
sourcePixels[textureWidth * 3 + 7] = new Color32(255, 255, 32, 255);
source.SetPixels32(sourcePixels);
source.Apply(false, false);
sprite = Sprite.Create(source, new Rect(0, 0, textureWidth, textureHeight),
new Vector2(.5f, .5f), 2f);
target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32)
{
filterMode = FilterMode.Point,
antiAliasing = 1,
};
target.Create();
cameraObject = new GameObject("Comparison flip render test camera");
Camera camera = cameraObject.AddComponent<Camera>();
cameraObject.AddComponent<UniversalAdditionalCameraData>().SetRenderer(1);
camera.orthographic = true;
camera.orthographicSize = 1f;
camera.aspect = (float)width / height;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = Color.black;
camera.targetTexture = target;
camera.cullingMask = 1 << FlipRenderTestLayer;
camera.transform.position = new Vector3(0f, 0f, -10f);
spriteObject = new GameObject("Asymmetric flip test sprite");
spriteObject.layer = FlipRenderTestLayer;
SpriteRenderer renderer = spriteObject.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sharedMaterial = material;
renderer.flipX = false;
capture = new Texture2D(width, height, TextureFormat.RGBA32, false);
yield return null;
Color[] forward = Capture(camera, target, capture);
renderer.flipX = true;
yield return null;
Color[] flipped = Capture(camera, target, capture);
int mismatchedPixels = 0;
int forwardBrightPixels = 0;
int flippedBrightPixels = 0;
int flipChangedPixels = 0;
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
{
Color expected = forward[y * width + (width - 1 - x)];
Color actual = flipped[y * width + x];
Color sameCoordinateForward = forward[y * width + x];
if (IsBright(forward[y * width + x])) forwardBrightPixels++;
if (IsBright(actual)) flippedBrightPixels++;
if (ColorDistance(sameCoordinateForward, actual) > .02f) flipChangedPixels++;
if (Mathf.Abs(expected.r - actual.r) + Mathf.Abs(expected.g - actual.g)
+ Mathf.Abs(expected.b - actual.b) + Mathf.Abs(expected.a - actual.a) > .02f)
mismatchedPixels++;
}
Assert.That(forwardBrightPixels, Is.GreaterThan(0), "The forward image must contain rendered sprite pixels.");
Assert.That(flippedBrightPixels, Is.GreaterThan(0), "The flipped image must contain rendered sprite pixels.");
Assert.That(flipChangedPixels, Is.GreaterThan(0), "Flipping this asymmetric sprite must change actual pixels.");
Assert.That(mismatchedPixels, Is.Zero,
"SpriteRenderer.flipX must mirror the rendered pixels, not merely expose a flip flag.");
}
finally
{
if (spriteObject != null) Object.Destroy(spriteObject);
if (cameraObject != null) Object.Destroy(cameraObject);
if (sprite != null) Object.Destroy(sprite);
if (source != null) Object.Destroy(source);
if (capture != null) Object.Destroy(capture);
if (target != null) { target.Release(); Object.Destroy(target); }
if (material != null) Object.Destroy(material);
}
}
[UnityTest]
public IEnumerator SharedSpriteMaterialStillRendersMeshAndLineGeometry()
{
const int width = 80;
const int height = 80;
GameObject cameraObject = null;
GameObject meshObject = null;
GameObject lineObject = null;
Texture2D capture = null;
RenderTexture target = null;
Material material = null;
Mesh mesh = null;
try
{
Shader shader = Shader.Find("BumpCombat/Comparison Sprite Depth");
Assert.That(shader, Is.Not.Null);
material = new Material(shader);
target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32);
target.Create();
cameraObject = new GameObject("Shared material render test camera");
Camera camera = cameraObject.AddComponent<Camera>();
cameraObject.AddComponent<UniversalAdditionalCameraData>().SetRenderer(1);
camera.orthographic = true;
camera.orthographicSize = 2f;
camera.aspect = 1f;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = Color.black;
camera.targetTexture = target;
camera.cullingMask = 1 << FlipRenderTestLayer;
camera.transform.position = new Vector3(0f, 0f, -10f);
meshObject = new GameObject("Ground-like MeshRenderer");
meshObject.layer = FlipRenderTestLayer;
mesh = new Mesh
{
vertices = new[]
{
new Vector3(-.6f, .1f, 0f), new Vector3(.6f, .1f, 0f),
new Vector3(-.6f, 1.3f, 0f), new Vector3(.6f, 1.3f, 0f),
},
uv = new[] { Vector2.zero, Vector2.right, Vector2.up, Vector2.one },
colors = new[] { Color.white, Color.white, Color.white, Color.white },
triangles = new[] { 0, 2, 1, 2, 3, 1 },
};
mesh.RecalculateBounds();
meshObject.AddComponent<MeshFilter>().sharedMesh = mesh;
meshObject.AddComponent<MeshRenderer>().sharedMaterial = material;
lineObject = new GameObject("Ground-like LineRenderer");
lineObject.layer = FlipRenderTestLayer;
LineRenderer line = lineObject.AddComponent<LineRenderer>();
line.useWorldSpace = true;
line.positionCount = 2;
line.startWidth = line.endWidth = .12f;
line.startColor = line.endColor = Color.red;
line.sharedMaterial = material;
line.SetPosition(0, new Vector3(-1.2f, -1f, 0f));
line.SetPosition(1, new Vector3(1.2f, -1f, 0f));
capture = new Texture2D(width, height, TextureFormat.RGBA32, false);
yield return null;
Color[] pixels = Capture(camera, target, capture);
int meshPixels = 0;
int linePixels = 0;
foreach (Color pixel in pixels)
{
if (pixel.r > .8f && pixel.g > .8f && pixel.b > .8f) meshPixels++;
if (pixel.r > .8f && pixel.g < .3f && pixel.b < .3f) linePixels++;
}
Assert.That(meshPixels, Is.GreaterThan(10),
"The shared shader must keep opaque floor/mesh geometry visible with default SpriteProps.");
Assert.That(linePixels, Is.GreaterThan(3),
"LineRenderer geometry using the shared comparison material must remain visible.");
}
finally
{
if (meshObject != null) Object.Destroy(meshObject);
if (lineObject != null) Object.Destroy(lineObject);
if (cameraObject != null) Object.Destroy(cameraObject);
if (capture != null) Object.Destroy(capture);
if (mesh != null) Object.Destroy(mesh);
if (target != null) { target.Release(); Object.Destroy(target); }
if (material != null) Object.Destroy(material);
}
}
private static Color[] Capture(Camera camera, RenderTexture target, Texture2D capture)
{
RenderTexture previous = RenderTexture.active;
camera.Render();
RenderTexture.active = target;
capture.ReadPixels(new Rect(0f, 0f, target.width, target.height), 0, 0);
capture.Apply(false, false);
Color[] pixels = capture.GetPixels();
RenderTexture.active = previous;
return pixels;
}
private static bool IsBright(Color color) => color.r > .25f || color.g > .25f || color.b > .25f;
private static float ColorDistance(Color a, Color b) => Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g)
+ Mathf.Abs(a.b - b.b) + Mathf.Abs(a.a - b.a);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18ab377df2c44fd9bb9967d73267ac35
@@ -0,0 +1,261 @@
using System.Collections;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DArtifactExecutorPlayModeTests
{
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private Defense25DLoadout loadout;
private Defense25DArtifactExecutor executor;
private float previousTimeScale;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
world = new GameObject("Artifact executor test world").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
world.Player.ResetAt(Vector2.zero);
loadout = new Defense25DLoadout(100f, 100f, 6, 2);
loadout.ConfigureCatalog(GameplayConstants.Current.Artifacts.Definitions);
executor = new Defense25DArtifactExecutor();
executor.Configure(world, loadout);
yield return new WaitForFixedUpdate();
}
[UnityTearDown]
public IEnumerator TearDown()
{
executor?.Cancel();
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator ChargeLockedInputUsesNormalArtifactOnRelease()
{
ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
Assert.That(phoenix, Is.Not.Null);
Assert.That(loadout.TryAddArtifact(phoenix), Is.True);
loadout.ChargeUnlocked = false;
Assert.That(executor.OnArtifactButtonPressed(), Is.True);
Assert.That(loadout.IsCharging, Is.False);
Assert.That(executor.OnArtifactButtonReleased(), Is.True);
Assert.That(loadout.Gauge, Is.EqualTo(100f - phoenix.NormalGaugeCost).Within(.001f));
yield return null;
}
[UnityTest]
public IEnumerator HurtPlayerCannotStartArtifactInput()
{
ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
Assert.That(loadout.TryAddArtifact(phoenix), Is.True);
Assert.That(world.Player.TakeDamage(1f, Vector2.left), Is.True);
Assert.That(executor.OnArtifactButtonPressed(), Is.False);
Assert.That(loadout.Gauge, Is.EqualTo(100f));
yield return null;
}
[UnityTest]
public IEnumerator DeadPlayerCannotStartArtifactInput()
{
ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
Assert.That(loadout.TryAddArtifact(phoenix), Is.True);
Assert.That(world.Player.TakeDamage(world.Player.CurrentHealth + 1f, Vector2.left), Is.True);
Assert.That(executor.OnArtifactButtonPressed(), Is.False);
Assert.That(loadout.Gauge, Is.EqualTo(100f));
yield return null;
}
[UnityTest]
public IEnumerator ActiveDashRejectsAnotherArtifactPress()
{
ActiveArtifactDefinition dash = loadout.FindArtifact(ActiveArtifactEffect.Dash);
Assert.That(loadout.TryAddArtifact(dash), Is.True);
loadout.ChargeUnlocked = false;
Assert.That(loadout.TryUse(false), Is.True);
Assert.That(executor.IsDashing, Is.True);
Assert.That(executor.OnArtifactButtonPressed(), Is.False);
yield return null;
}
[UnityTest]
public IEnumerator ChainLightningDamagesOnlyAfterItsAuthoredTravelInterval()
{
ActiveArtifactDefinition chain = loadout.FindArtifact(ActiveArtifactEffect.ChainLightning);
Assert.That(chain, Is.Not.Null);
Assert.That(loadout.TryAddArtifact(chain), Is.True);
Defense25DEnemy enemy = world.Enemies[0];
enemy.Spawn(Vector2.right * .5f);
Physics.SyncTransforms();
float health = enemy.Actor.CurrentHealth;
Assert.That(loadout.TryUse(false), Is.True);
executor.Step(Mathf.Max(.01f, chain.HitInterval * .5f));
Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(health));
executor.Step(Mathf.Max(.01f, chain.HitInterval));
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health));
yield return null;
}
[UnityTest]
public IEnumerator DamageHitPublishesCombatAndRelicNotifications()
{
ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
Assert.That(loadout.TryAddArtifact(phoenix), Is.True);
Defense25DEnemy enemy = world.Enemies[0];
enemy.Spawn(Vector2.right * .5f);
Physics.SyncTransforms();
bool validHit = false;
bool relicHit = false;
void OnValidHit(CombatHitResult hit)
{
if (hit.Target == enemy.gameObject && hit.IsArtifactHit) validHit = true;
}
CombatEvents.OnValidHit += OnValidHit;
executor.NotifyRelicHit = _ => relicHit = true;
try
{
Assert.That(loadout.TryUse(false), Is.True);
Assert.That(validHit, Is.True);
Assert.That(relicHit, Is.True);
}
finally
{
CombatEvents.OnValidHit -= OnValidHit;
}
yield return null;
}
[UnityTest]
public IEnumerator CycloneHitsEnemyWhoseBodyColliderOverlapsEllipse()
{
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
Defense25DEnemy enemy = world.Enemies[0];
Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
Vector2 radii = CycloneGeometry.Radii(cyclone.NormalRange, false);
Vector2 intendedBodyCenter = center + Vector2.right * (radii.x + enemy.Actor.Radius * .6f);
enemy.Spawn(intendedBodyCenter - enemy.Actor.Offset);
Physics.SyncTransforms();
Assert.That(CycloneGeometry.NormalizedDistance(enemy.Actor.Position - center, radii), Is.GreaterThan(1f),
"The actor anchor must be outside the authored ellipse for this body-overlap case.");
float colliderPlaneY = settings.GroundY + enemy.Actor.BodyCollider.center.y;
Assert.That(CycloneGeometry.Overlaps(center, radii, point =>
Defense25DActor.Ground(enemy.Actor.BodyCollider.ClosestPoint(
Defense25DActor.OnGround(point, colliderPlaneY)))),
Is.True, "The body collider must overlap the authored ellipse.");
float health = enemy.Actor.CurrentHealth;
Assert.That(loadout.TryUse(false), Is.True);
executor.Step(cyclone.NormalDuration + .05f);
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health));
yield return null;
}
[UnityTest]
public IEnumerator RelicCallbackMayQueryAgainWhileCycloneEnumeratesHits()
{
world.SetPaused(false);
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
Defense25DEnemy first = world.Enemies[0];
Defense25DEnemy second = world.Enemies[1];
Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
first.Spawn(center + Vector2.left * .1f);
second.Spawn(center + Vector2.right * .1f);
Physics.SyncTransforms();
float firstHealth = first.Actor.CurrentHealth;
float secondHealth = second.Actor.CurrentHealth;
executor.NotifyRelicHit = _ => world.QueryEnemies(new Vector2(20f, 20f), Vector2.one);
Assert.That(loadout.TryUse(false), Is.True);
executor.Step(cyclone.NormalDuration + .05f);
Assert.That(first.Actor.CurrentHealth, Is.LessThan(firstHealth));
Assert.That(second.Actor.CurrentHealth, Is.LessThan(secondHealth));
yield return null;
}
[UnityTest]
public IEnumerator PhoenixRayUsesGroundAnchorHalfWidthWithoutActorRadiusPadding()
{
ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
Assert.That(loadout.TryAddArtifact(phoenix), Is.True);
Defense25DEnemy enemy = world.Enemies[0];
float across = phoenix.NormalWidth * .5f + enemy.Actor.Radius * .5f;
enemy.Spawn(new Vector2(.5f, across));
Physics.SyncTransforms();
float health = enemy.Actor.CurrentHealth;
Assert.That(loadout.TryUse(false), Is.True);
Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(health));
yield return null;
}
[UnityTest]
public IEnumerator CycloneIsRegisteredTicksAndReleasesItsExecutionLock()
{
ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
Assert.That(loadout.TryAddArtifact(cyclone), Is.True);
Defense25DEnemy enemy = world.Enemies[0];
Vector2 hitCenter = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale);
enemy.Spawn(hitCenter);
Physics.SyncTransforms();
float health = enemy.Actor.CurrentHealth;
Assert.That(loadout.TryUse(false), Is.True);
Assert.That(executor.IsExecutingArtifact, Is.True);
executor.Step(cyclone.NormalDuration + .05f);
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health));
Assert.That(executor.IsExecutingArtifact, Is.False);
yield return null;
}
[UnityTest]
public IEnumerator ThunderCrashIsRegisteredDamagesAndReleasesItsExecutionLock()
{
ActiveArtifactDefinition thunder = loadout.FindArtifact(ActiveArtifactEffect.ThunderCrash);
Assert.That(loadout.TryAddArtifact(thunder), Is.True);
Defense25DEnemy enemy = world.Enemies[0];
enemy.Spawn(Vector2.right * .48f);
Physics.SyncTransforms();
float health = enemy.Actor.CurrentHealth;
Assert.That(loadout.TryUse(false), Is.True);
Assert.That(executor.IsExecutingArtifact, Is.True);
executor.Step(thunder.NormalDuration + .05f);
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health));
Assert.That(executor.IsExecutingArtifact, Is.False);
yield return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 62ae67a34a5f4b1893f15bb4a948fe33
@@ -0,0 +1,168 @@
using System.IO;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DBodyColliderPlayModeTests
{
private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
private const float Ppu = 32f;
private Scene ownedTestScene;
[UnityTest]
public System.Collections.IEnumerator BodyCoreCapsulesMatchFixedFamilyScreenBoundsAndCenters()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
ownedTestScene = SceneManager.GetActiveScene();
Defense25DComparison world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
world.ShowBoundaries = true;
world.SetPaused(true);
Vector2 origin = new(0f, -2f);
world.Player.ResetAt(origin + Vector2.left * 4f);
world.Enemies[0].Spawn(origin + Vector2.left * 2f);
Defense25DActor slime = Summon(world, "Body Slime", origin, 1, DefenseTowerBranch.First);
Defense25DActor giant = Summon(world, "Body Giant", origin + Vector2.right * 2f, 4, DefenseTowerBranch.First);
Defense25DActor lava = Summon(world, "Body Lava", origin + Vector2.right * 4f, 4, DefenseTowerBranch.Second);
lava.Facing = Vector2.left;
Physics.SyncTransforms();
yield return null;
yield return null;
Defense25DActor[] actors = { world.Player, world.Enemies[0].Actor, slime, giant, lava };
string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT");
if (string.IsNullOrWhiteSpace(output)) output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures");
Directory.CreateDirectory(output);
string path = Path.Combine(output, "body-collider-overlay.png");
world.View.Camera.Render();
RenderTexture.active = world.View.Texture;
Texture2D image = new(world.View.Texture.width, world.View.Texture.height, TextureFormat.RGBA32, false);
image.ReadPixels(new Rect(0, 0, image.width, image.height), 0, 0);
image.Apply();
File.WriteAllBytes(path, image.EncodeToPNG());
Object.DestroyImmediate(image);
RenderTexture.active = null;
// Player/Skeleton retain their existing 2D-collider-derived baseline.
AssertShape(world.Player, .275f, .85f);
AssertShape(world.Enemies[0].Actor, .275f, .85f);
// Fixed inscribed body cores from the shadowless Idle audit, 32 PPU, actual variant render scale.
AssertShape(slime, .1890625f, .378125f); // 11px inner body core * 1.1 / 32
AssertShape(giant, .5328125f, 1.065625f); // 22px inner body core * 1.55 / 32
AssertShape(lava, .23359375f, .4671875f); // 13px inner body core * 1.15 / 32
AssertSkeletonCenter(world, world.Enemies[0].Actor);
AssertBodyCenter(world, slime, -1.5f, -1.5f);
AssertBodyCenter(world, giant, -3f, 17f);
AssertBodyCenter(world, lava, -2f, -.5f);
AssertCapsuleScreenCore(world, slime, 11f);
AssertCapsuleScreenCore(world, giant, 22f);
AssertCapsuleScreenCore(world, lava, 13f);
Defense25DActor probe = Summon(world, "Body Slime contact probe",
origin + Vector2.right * .37f, 1, DefenseTowerBranch.First);
Physics.SyncTransforms();
Assert.That(slime.GapTo(probe), Is.EqualTo(-.008125f).Within(.002f));
Assert.That(Physics.ComputePenetration(slime.BodyCollider, slime.transform.position,
slime.transform.rotation, probe.BodyCollider, probe.transform.position,
probe.transform.rotation, out _, out _), Is.True,
"The actual capsule bodies overlap at the tested body-core contact distance.");
probe.ResetAt(origin + Vector2.right * .4f);
Physics.SyncTransforms();
Assert.That(slime.GapTo(probe), Is.EqualTo(.021875f).Within(.002f));
Assert.That(Physics.ComputePenetration(slime.BodyCollider, slime.transform.position,
slime.transform.rotation, probe.BodyCollider, probe.transform.position,
probe.transform.rotation, out _, out _), Is.False,
"The actual capsule bodies miss beyond the tested body-core boundary.");
Assert.That(File.Exists(path), Is.True);
yield return null;
}
[UnityTearDown]
public System.Collections.IEnumerator UnloadOwnedTestScene()
{
Time.timeScale = 1f;
if (ownedTestScene.IsValid() && ownedTestScene.isLoaded)
{
Scene cleanup = SceneManager.CreateScene("BodyColliderTestCleanup");
SceneManager.SetActiveScene(cleanup);
AsyncOperation unload = SceneManager.UnloadSceneAsync(ownedTestScene);
if (unload != null) yield return unload;
}
ownedTestScene = default;
}
private static void AssertShape(Defense25DActor actor, float radius, float height)
{
Assert.That(actor.Radius, Is.EqualTo(radius).Within(.0001f), actor.name + " combat radius");
Assert.That(actor.BodyCollider.radius, Is.EqualTo(radius).Within(.0001f), actor.name + " capsule radius");
Assert.That(actor.BodyCollider.height, Is.EqualTo(height).Within(.0001f), actor.name + " capsule height");
}
private static void AssertBodyCenter(Defense25DComparison world, Defense25DActor actor,
float centerXFromPivotPixels, float centerUpFromPivotPixels)
{
float scale = actor.Visual.VisualScale;
Vector3 anchor = world.View.Camera.WorldToScreenPoint(actor.Visual.FootPosition);
Vector3 center = world.View.Camera.WorldToScreenPoint(
actor.BodyCollider.transform.TransformPoint(actor.BodyCollider.center));
float pixelsPerUnit = world.View.Camera.pixelHeight / (2f * world.View.Camera.orthographicSize);
float footCorrectionPixels = -actor.Visual.FootVisualOffset * Ppu / scale;
float facingSign = actor.Facing.x < 0f ? -1f : 1f;
float expectedX = centerXFromPivotPixels * facingSign * scale / Ppu * pixelsPerUnit;
float expectedY = (centerUpFromPivotPixels + footCorrectionPixels) * scale / Ppu * pixelsPerUnit;
Assert.That(center.x - anchor.x, Is.EqualTo(expectedX).Within(1.5f), actor.name + " projected body center X");
Assert.That(center.y - anchor.y, Is.EqualTo(expectedY).Within(1.5f), actor.name + " projected body center Y");
}
private static void AssertSkeletonCenter(Defense25DComparison world, Defense25DActor skeleton)
{
Camera camera = world.View.Camera;
Vector3 anchor = camera.WorldToScreenPoint(skeleton.Visual.FootPosition);
Vector3 center = camera.WorldToScreenPoint(
skeleton.BodyCollider.transform.TransformPoint(skeleton.BodyCollider.center));
float pixelsPerUnit = camera.pixelHeight / (2f * camera.orthographicSize);
float currentCenterHeight = Vector3.Dot(camera.transform.up,
Vector3.up * (skeleton.BodyCollider.height * .5f)) * pixelsPerUnit;
Assert.That(center.y - anchor.y, Is.EqualTo(currentCenterHeight + 3f).Within(1f),
"Skeleton keeps its 2D radius and raises the fixed 2.5D capsule center by the reviewed three internal pixels.");
}
private static void AssertCapsuleScreenCore(Defense25DComparison world,
Defense25DActor actor, float auditedCoreDiameterPixels)
{
float pixelsPerUnit = world.View.Camera.pixelHeight / (2f * world.View.Camera.orthographicSize);
float actualDiameterPixels = actor.BodyCollider.radius * 2f * pixelsPerUnit;
float expectedDiameterPixels = auditedCoreDiameterPixels * actor.Visual.VisualScale
/ Ppu * pixelsPerUnit;
Assert.That(actualDiameterPixels, Is.EqualTo(expectedDiameterPixels).Within(.5f),
actor.name + " projected capsule diameter must fit inside the literal audited body core height.");
}
private static Defense25DActor Summon(Defense25DComparison world, string name,
Vector2 position, int tier, DefenseTowerBranch branch)
{
GameObject root = new(name);
root.transform.SetParent(world.transform, false);
root.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY);
Defense25DActor actor = root.AddComponent<Defense25DActor>();
actor.Configure(world, ComparisonActorKind.Summon);
actor.Visual.ConfigureSummonArt(tier, branch);
actor.ResetAt(position);
return actor;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70ceeaf7eb944594994b668154b4459f
@@ -0,0 +1,328 @@
using System.Collections;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DCombatFeedbackPlayModeTests
{
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private Defense25DView view;
private float previousTimeScale;
private float previousBlinkInterval;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
previousBlinkInterval = GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval;
Time.timeScale = 1f;
settings = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first.");
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
GameObject viewObject = new("Feedback render camera");
Camera camera = viewObject.AddComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 6.5f;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(.07f, .09f, .11f, 1f);
view = viewObject.AddComponent<Defense25DView>();
world = new GameObject("Combat feedback world under test").AddComponent<Defense25DComparison>();
world.Initialize(settings, view);
camera.transform.position = new Vector3(0f, 12f, -12f);
camera.transform.LookAt(new Vector3(0f, settings.GroundY, 0f));
world.AutoSpawn = false;
world.ReadInput = false;
world.Player.ResetAt(Vector2.zero);
yield return new WaitForFixedUpdate();
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (view != null) Object.Destroy(view.gameObject);
if (settings != null) Object.Destroy(settings);
GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval = previousBlinkInterval;
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator HiddenArtifactContactCreatesOnlyTheAuthoredContactImpact()
{
Time.timeScale = .37f;
Defense25DCombatFeedback feedback = world.CombatFeedback;
Defense25DEnemy target = world.Enemies[0];
CombatHitResult contact = CombatHitResult.ForArtifactContact(
world.Player.gameObject, target.gameObject, ActiveArtifactEffect.Pulse, false,
HitSide.Front, Vector2.right, target.Actor.Position, DamageTag.Collision,
"feedback-contact-test");
feedback.NotifyValidHit(contact);
Assert.That(feedback.ActiveArtifactImpactCount, Is.EqualTo(1));
Assert.That(Time.timeScale, Is.EqualTo(.37f),
"A hidden/zero-damage contact gets its source sprite but must not request hit-stop.");
SpriteRenderer impact = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name.StartsWith("Artifact Impact "));
Assert.That(impact, Is.Not.Null);
Sprite[] authoredFrames = Resources.LoadAll<Sprite>(
CombatFeedback.GetArtifactImpactResourcePath(ActiveArtifactEffect.Pulse, false));
System.Array.Sort(authoredFrames, (a, b) => string.CompareOrdinal(a.name, b.name));
Assert.That(authoredFrames, Has.Length.EqualTo(CombatFeedback.ArtifactImpactFrameCount));
Assert.That(impact.sprite, Is.SameAs(authoredFrames[0]));
Assert.That(impact.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
yield return null;
}
[UnityTest]
public IEnumerator PlayerAlpha035UsesTransparentMaterialAndRendersIt()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback;
float blinkInterval = tuning.InvulnerabilityBlinkInterval;
SpriteRenderer player = world.Player.Visual.Renderer;
Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null);
Assert.That(feedback.TransparentEffectMaterial.GetFloat("_Cutoff"), Is.EqualTo(.001f).Within(.0001f));
Assert.That(feedback.TransparentEffectMaterial.GetColor("_BaseColor"), Is.EqualTo(Color.white));
feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right,
GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold);
// With the authored blink interval temporarily disabled, the same hurt/flash scene
// is the opaque capture baseline. Re-enabling it produces the source alpha-.35 phase.
tuning.InvulnerabilityBlinkInterval = 0f;
feedback.TickFeedback(0f, 0f);
yield return null;
Color[] opaqueCapture = CaptureView();
tuning.InvulnerabilityBlinkInterval = blinkInterval;
feedback.TickFeedback(0f, 0f);
yield return null;
Assert.That(player.color.a, Is.EqualTo(.35f).Within(.001f));
Assert.That(player.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial),
"The alpha test must select the unclipped, cloned transparent effect material.");
Color[] fadedCapture = CaptureView();
int changedPixels = 0;
for (int i = 0; i < opaqueCapture.Length; i++)
{
Color a = opaqueCapture[i];
Color b = fadedCapture[i];
if (Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g) + Mathf.Abs(a.b - b.b) > .08f)
changedPixels++;
}
Assert.That(changedPixels, Is.GreaterThan(0),
"A camera render capture must show the player changing at alpha .35; cutout clipping is not acceptable.");
tuning.InvulnerabilityBlinkInterval = blinkInterval;
feedback.ResetFeedback();
yield return null;
}
[UnityTest]
public IEnumerator HurtUsesSourceKnockbackDirectionForImpactOriginAndOwnsHitStop()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Vector2 playerPosition = world.Player.Position;
Vector2 knockbackDirection = new(.6f, .8f);
Vector3 worldPosition = world.Player.transform.position;
float heavyThreshold = GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold;
feedback.NotifyPlayerHurt(worldPosition, knockbackDirection, heavyThreshold);
SpriteRenderer impact = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Player Hurt Impact");
Assert.That(impact, Is.Not.Null);
Vector2 expectedOrigin = playerPosition - knockbackDirection.normalized * .18f;
Assert.That(impact.transform.position.x, Is.EqualTo(expectedOrigin.x).Within(.001f));
Assert.That(impact.transform.position.z, Is.EqualTo(expectedOrigin.y).Within(.001f));
Assert.That(feedback.ActivePlayerHurtImpactCount, Is.EqualTo(1));
Assert.That(Time.timeScale, Is.Zero);
feedback.ResetFeedback();
Assert.That(Time.timeScale, Is.EqualTo(1f), "Reset releases only the hit-stop owned by this feedback component.");
yield return null;
}
[UnityTest]
public IEnumerator ResetDoesNotChangeAnExternalPauseAndClearsOwnedVisuals()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Time.timeScale = 0f;
feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right, 0f);
feedback.ResetFeedback();
Assert.That(Time.timeScale, Is.Zero,
"Reset must not claim or restore a time scale that was already externally paused.");
Assert.That(feedback.ActiveEffectCount, Is.Zero);
Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null,
"Reset keeps the bound world's cloned material for the next event.");
yield return null;
}
[UnityTest]
public IEnumerator ChargeReadyFlashSwitchAndReleaseUseTheirAuthoredSheets()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse);
Assert.That(artifact, Is.Not.Null);
Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True);
feedback.NotifyChargeChanged(artifact, .5f, false);
Assert.That(feedback.IsChargingVisualVisible, Is.True);
feedback.NotifyChargeChanged(artifact, 1f, true);
Assert.That(feedback.IsReadyFlashVisible, Is.True,
"The four-frame Flash sheet is validated against FlashFrameCount, not the six-frame aura count.");
Sprite[] flashFrames = Resources.LoadAll<Sprite>(
Defense25DCombatFeedbackConstants.ChargeResourceRoot + artifact.Effect
+ Defense25DCombatFeedbackConstants.ChargeFlashSuffix);
Assert.That(flashFrames, Has.Length.EqualTo(ArtifactChargeVisual.FlashFrameCount));
feedback.NotifyArtifactSwitched(artifact);
SpriteRenderer switchPulse = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name.Contains("Artifact Charge ")
&& renderer.gameObject.name.Contains(Defense25DCombatFeedbackConstants.ChargeReleaseSuffix));
Assert.That(switchPulse, Is.Not.Null);
Assert.That(switchPulse.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
feedback.NotifyArtifactReleased(artifact, world.Player.Position, true);
SpriteRenderer release = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Release");
Assert.That(release, Is.Not.Null);
Assert.That(feedback.ActiveEffectCount, Is.GreaterThan(0));
feedback.ResetFeedback();
Assert.That(feedback.ActiveEffectCount, Is.Zero);
yield return null;
}
[UnityTest]
public IEnumerator GuardBlockedBumpAndTowerFeedbackUseOriginalSprites()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Defense25DEnemy target = world.Enemies[0];
feedback.NotifyGuardImpact(Vector2.left);
feedback.NotifyBlockedBump(target.Actor.Position, Vector2.right);
feedback.NotifyTowerImpact(DefenseTowerKind.Direct, target, target.Actor.Position);
SpriteRenderer guard = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Player Guard Block Impact");
SpriteRenderer blocked = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Blocked Bump");
SpriteRenderer tower = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Tower Impact Direct");
Assert.That(guard, Is.Not.Null);
Assert.That(blocked, Is.Not.Null);
Assert.That(blocked.flipX, Is.False);
Assert.That(tower, Is.Not.Null);
Assert.That(guard.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
Assert.That(blocked.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
Assert.That(tower.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
Sprite[] towerFrames = Resources.LoadAll<Sprite>(CombatFeedback.GetTowerImpactResourcePath(DefenseTowerKind.Direct));
Assert.That(towerFrames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount));
Assert.That(tower.sprite, Is.SameAs(towerFrames[0]));
feedback.ResetFeedback();
Assert.That(feedback.ActiveEffectCount, Is.Zero);
yield return null;
}
[UnityTest]
public IEnumerator ShieldPulseAndEnemyDustEffectsRemainWithinHeavyEffectCap()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Defense25DEnemy target = world.Enemies[0];
Vector2 start = target.Actor.Position;
Vector2 finish = start + Vector2.right * .65f;
feedback.NotifyShield(.2f);
feedback.NotifyPulseGather(target, start, finish);
feedback.NotifyKnockbackCompleted(target.Actor, start, finish);
for (int i = 0; i < CombatFeedback.MaxConcurrentHeavyEffects + 4; i++)
feedback.NotifyShield(.2f);
Assert.That(feedback.ActiveHeavyEffectCount, Is.LessThanOrEqualTo(CombatFeedback.MaxConcurrentHeavyEffects));
Assert.That(Object.FindObjectsByType<LineRenderer>(FindObjectsSortMode.None)
.Any(line => line.gameObject.name == "Interaction Ring"), Is.True);
Assert.That(Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.Any(renderer => renderer.gameObject.name == "Pulse Gather Afterimage"), Is.True);
feedback.ResetFeedback();
Assert.That(feedback.ActiveHeavyEffectCount, Is.Zero);
yield return null;
}
[UnityTest]
public IEnumerator ShieldRingStartsAndFollowsThePlayerVisualGroundAnchor()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
world.SetPaused(false);
Vector3 foot = world.Player.Visual.FootPosition;
Vector2 initialAnchor = new(foot.x, foot.z);
Assert.That(Vector2.Distance(world.Player.Position, initialAnchor), Is.GreaterThan(.01f),
"Fixture must retain the player's authored foot-to-capsule offset so the old body anchor would fail.");
feedback.NotifyShield(.5f);
LineRenderer ring = Object.FindObjectsByType<LineRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(line => line.gameObject.name == "Interaction Ring");
Assert.That(ring, Is.Not.Null);
Assert.That(ring.transform.position.x, Is.EqualTo(initialAnchor.x).Within(.001f));
Assert.That(ring.transform.position.z, Is.EqualTo(initialAnchor.y).Within(.001f));
Vector2 movedPosition = world.Player.Position + new Vector2(1.1f, -.65f);
world.Player.ResetAt(movedPosition);
Physics.SyncTransforms();
yield return null;
yield return null;
Vector3 movedFoot = world.Player.Visual.FootPosition;
feedback.TickFeedback(.016f, .016f);
Assert.That(ring.transform.position.x, Is.EqualTo(movedFoot.x).Within(.001f));
Assert.That(ring.transform.position.z, Is.EqualTo(movedFoot.z).Within(.001f));
Vector2 ringGround = Defense25DActor.Ground(ring.transform.position);
Assert.That(Vector2.Distance(ringGround, world.Player.Position), Is.GreaterThan(.01f));
yield return null;
}
private Color[] CaptureView()
{
RenderTexture previous = RenderTexture.active;
Texture2D capture = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGBA32, false);
view.Camera.Render();
RenderTexture.active = view.Texture;
capture.ReadPixels(new Rect(0f, 0f, Defense25DView.Width, Defense25DView.Height), 0, 0);
capture.Apply();
Color[] pixels = capture.GetPixels();
RenderTexture.active = previous;
Object.Destroy(capture);
return pixels;
}
private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack)
{
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
.OfType<Sprite>()
.OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame)
? frame : 0)
.ToArray();
return new ComparisonSpriteFrames
{
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack),
Hurt = Read("Hurt"), Death = Read("Death"),
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 57c219ff31f44c4b991a2ffb62de14fd
@@ -0,0 +1,205 @@
using System;
using System.Collections;
using System.Linq;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DCombatPopupPlayModeTests
{
private const string SettingsPath =
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private float previousTimeScale;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
settings = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
settings.EnemyArt = ReadCharacter();
world = new GameObject("2.5D combat popup test world")
.AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator ValidHitPopups_MatchNormalSideBackAndArtifactContracts()
{
GameObject target = world.Enemies[0].gameObject;
RaiseHit(target, HitSide.Front, 31.9f);
AssertPopup(0, "31", Color.white);
world.ResetRun();
target = world.Enemies[0].gameObject;
RaiseHit(target, HitSide.Side, 22.8f);
AssertPopup(0, "SIDE ×1.2 22", new Color(0.65f, 1f, 0.3f));
world.ResetRun();
target = world.Enemies[0].gameObject;
RaiseHit(target, HitSide.Back, 9.8f);
AssertPopup(0, "BACK ×1.5 9", new Color(1f, 0.75f, 0.2f));
world.ResetRun();
target = world.Enemies[0].gameObject;
CombatEvents.RaiseValidHit(new CombatHitResult(
world.Player.gameObject, target, true, HitSide.Front, 13f,
Vector2.right, 0f, Vector2.zero, launchSucceeded: true));
AssertPopup(0, "LAUNCH 13", new Color(0.95f, 0.55f, 1f));
world.ResetRun();
target = world.Enemies[0].gameObject;
ActiveArtifactDefinition dash = world.Loadout.FindArtifact(ActiveArtifactEffect.Dash);
Assert.That(dash, Is.Not.Null);
CombatEvents.RaiseValidHit(new CombatHitResult(
world.Player.gameObject, target, true, HitSide.Front, 15f, Vector2.right,
0f, Vector2.zero, sourceId: dash.ArtifactId, isArtifactHit: true,
artifactEffect: ActiveArtifactEffect.Dash));
AssertPopup(0, $"{dash.DisplayName} 15",
ActiveArtifactDefinition.GetArtifactPaletteColor(dash.ArtifactColor));
world.ResetRun();
target = world.Enemies[0].gameObject;
ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
Assert.That(cyclone, Is.Not.Null);
CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact(
world.Player.gameObject, target, ActiveArtifactEffect.Cyclone, false,
HitSide.Front, Vector2.right, Vector2.zero, cyclone.DamageTag, cyclone.ArtifactId));
AssertPopup(0, $"{cyclone.DisplayName} 0",
ActiveArtifactDefinition.GetArtifactPaletteColor(cyclone.ArtifactColor));
yield return null;
}
[UnityTest]
public IEnumerator PopupEvents_AreWorldScopedResettableAndRemovedOnDisable()
{
GameObject worldTarget = world.Enemies[0].gameObject;
GameObject unrelatedTarget = new("Unrelated popup target");
GameObject unrelatedAttacker = new("Unrelated popup attacker");
try
{
RaiseHit(unrelatedTarget, HitSide.Front, 10f);
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero);
CombatEvents.RaiseValidHit(new CombatHitResult(
unrelatedAttacker, worldTarget, false, HitSide.Front, 10f,
Vector2.right, 0f, Vector2.zero));
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero,
"Hits from another attacker against this world's enemy are ignored.");
RaiseHit(worldTarget, HitSide.Front, 10f);
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1));
world.ResetRun();
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero);
world.enabled = false;
RaiseHit(worldTarget, HitSide.Front, 10f);
world.NotifyPlayerDamagePopup(10f, Vector3.zero);
world.RecordTowerDamagePopup(10f, Color.cyan, Vector3.one);
world.enabled = true;
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero,
"A disabled comparison must reject event and direct popup additions.");
RaiseHit(world.Enemies[0].gameObject, HitSide.Front, 10f);
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1),
"Re-enabling the comparison must restore one event subscription.");
}
finally
{
Object.Destroy(unrelatedTarget);
Object.Destroy(unrelatedAttacker);
}
yield return null;
}
[UnityTest]
public IEnumerator PlayerAndTowerDamagePopups_ShareTwelveReusableUnscaledSlots()
{
for (int i = 0; i < 13; i++)
world.NotifyPlayerDamagePopup(12.9f, Vector3.zero);
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(12));
AssertPopup(0, "-12", new Color(244f / 255f, 117f / 255f, 112f / 255f));
world.RecordTowerDamagePopup(8.9f, Color.cyan, Vector3.one);
Assert.That(world.ActiveTowerDamagePopupCount, Is.Zero,
"An active-set overflow must be rejected for towers as well.");
Time.timeScale = 0f;
yield return new WaitForSecondsRealtime(0.65f);
Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero,
"Popup expiry uses unscaled time, including while paused.");
world.NotifyPlayerDamagePopup(14.5f, Vector3.zero);
world.RecordTowerDamagePopup(8.9f, Color.cyan, Vector3.one);
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(2));
Assert.That(world.ActiveTowerDamagePopupCount, Is.EqualTo(1));
AssertPopup(0, "-14", new Color(244f / 255f, 117f / 255f, 112f / 255f));
AssertPopup(1, "-8", Color.cyan);
world.NotifyPlayerDamagePopup(0f, Vector3.zero);
Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(2),
"The public player damage API ignores zero actual HP loss.");
}
private void RaiseHit(GameObject target, HitSide side, float damage)
{
CombatEvents.RaiseValidHit(new CombatHitResult(
world.Player.gameObject, target, false, side, damage, Vector2.right,
0f, Vector2.zero, isOffsetHit: false));
}
private void AssertPopup(int index, string text, Color color)
{
FieldInfo popupArrayField = typeof(Defense25DComparison).GetField(
"combatDamagePopups", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(popupArrayField, Is.Not.Null);
Array popupArray = (Array)popupArrayField.GetValue(world);
object popup = popupArray.GetValue(index);
Type popupType = popup.GetType();
Assert.That((string)popupType.GetField("Text").GetValue(popup), Is.EqualTo(text));
Color actualColor = (Color)popupType.GetField("Color").GetValue(popup);
Assert.That(actualColor.r, Is.EqualTo(color.r).Within(0.002f));
Assert.That(actualColor.g, Is.EqualTo(color.g).Within(0.002f));
Assert.That(actualColor.b, Is.EqualTo(color.b).Within(0.002f));
}
private static ComparisonSpriteFrames ReadCharacter()
{
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
$"Assets/_Project/Art/Characters/Slime/Slime_{suffix}.png")
.OfType<Sprite>()
.OrderBy(sprite => int.TryParse(
sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0)
.ToArray();
return new ComparisonSpriteFrames
{
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read("Attack"),
Hurt = Read("Hurt"), Death = Read("Death"),
};
}
}
}
@@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: d51c6f9d985c46bea7689db196a237cb
labels:
- Test
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,99 @@
using System.Collections;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DComparisonHudLayoutPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private Camera projectionCamera;
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
[UnitySetUp]
public IEnumerator SetUp()
{
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
world = new GameObject("HUD geometry world").AddComponent<Defense25DComparison>();
world.Initialize(settings);
GameObject cameraObject = new("HUD pad projection camera");
projectionCamera = cameraObject.AddComponent<Camera>();
projectionCamera.orthographic = true;
projectionCamera.orthographicSize = settings.CameraSize;
projectionCamera.aspect = (float)Defense25DView.Width / Defense25DView.Height;
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
projectionCamera.rect = new Rect(viewport.x / Screen.width, viewport.y / Screen.height,
viewport.width / Screen.width, viewport.height / Screen.height);
projectionCamera.transform.SetPositionAndRotation(
new Vector3(0f, 13f, -11f), Quaternion.Euler(settings.CameraPitch, 0f, 0f));
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (projectionCamera != null) Object.Destroy(projectionCamera.gameObject);
if (settings != null) Object.Destroy(settings);
yield return null;
}
[UnityTest]
public IEnumerator PersistentHudControlsLeaveAllSixProjectedPadsClearAndConsumeOnlyTheirDrawnRegions()
{
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
float scale = viewport.width / Defense25DView.Width;
Rect[] controls = world.GetPersistentDefenseUiHitRects();
Assert.That(controls, Is.Not.Empty);
for (int pad = 0; pad < 6; pad++)
{
Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad);
Vector3 screen = projectionCamera.WorldToScreenPoint(
new Vector3(ground.x, settings.GroundY, ground.y));
Vector2 gui = new((screen.x - viewport.x) / scale,
(Screen.height - screen.y - viewport.y) / scale);
Rect padFootprint = new(gui.x - 14f, gui.y - 14f, 28f, 28f);
Assert.That(controls.Any(control => control.Overlaps(padFootprint)), Is.False,
$"Persistent HUD control covers projected pad {pad + 1} at {gui}.");
Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(viewport, scale, gui)), Is.False,
$"A click on projected pad {pad + 1} is incorrectly captured by the HUD.");
}
foreach (Rect control in controls)
{
Vector2 center = control.center;
Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(viewport, scale, center)), Is.True,
$"Drawn HUD control at {center} is not protected from combat input.");
}
Rect noDetails = world.GetSelectedPadDetailsRect();
Assert.That(noDetails.width <= 0f || noDetails.height <= 0f, Is.True,
"Detailed construction controls should not occupy arena space before a pad is selected.");
Assert.That(world.SelectBuildPad(0), Is.True);
Rect details = world.GetSelectedPadDetailsRect();
Assert.That(details.width, Is.GreaterThan(0f));
Assert.That(details.height, Is.GreaterThan(0f));
Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(viewport, scale, details.center)), Is.True,
"The contextual detail panel's click area must match its drawn panel.");
yield return null;
}
private static Vector2 ScreenPoint(Rect viewport, float scale, Vector2 gui) =>
new(viewport.x + gui.x * scale,
Screen.height - (viewport.y + gui.y * scale));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 90b66cbb8eb94b1091252ef9220d3ab5
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: efe5ea77be2b4a86bf2248e9c77fe623
@@ -0,0 +1,157 @@
using System.Collections;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using BumpCombat.Progression;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DComparisonPresentationPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private float previousTimeScale;
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
settings.EnemyArt = ReadCharacter();
world = new GameObject("Presentation integration world").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
yield return new WaitForFixedUpdate();
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator BuildCatalogUsesAllSixRealTowerSpritesAndBuildsSelectedTypeAtSelectedPad()
{
Assert.That(world.TowerShowcaseKinds.Count(), Is.EqualTo(6));
foreach (DefenseTowerKind kind in world.TowerShowcaseKinds)
Assert.That(world.GetTowerShowcaseSprite(kind), Is.Not.Null, $"Missing actual idle art for {kind}");
const int pad = 2;
Vector2 position = GameplayConstants.Current.Defense.GetBuildPadPosition(pad);
world.Player.ResetAt(position);
Assert.That(world.SelectBuildKind(DefenseTowerKind.Lightning), Is.True);
Assert.That(world.SelectBuildPad(pad), Is.True);
int before = world.TowerManager.ConstructionCurrency;
Assert.That(world.BuildSelectedTower(), Is.True);
Defense25DTower tower = world.TowerManager.GetTowerAtPad(pad);
Assert.That(tower, Is.Not.Null);
Assert.That(tower.Kind, Is.EqualTo(DefenseTowerKind.Lightning));
Assert.That(world.TowerManager.ConstructionCurrency, Is.LessThan(before));
yield return null;
}
[UnityTest]
public IEnumerator SelectedTowerOffersOnlyLegalTierAndFinalBranchUpgrades()
{
const int pad = 0;
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad));
world.TowerManager.AddCurrency(1000);
Assert.That(world.SelectBuildPad(pad), Is.True);
Assert.That(world.BuildSelectedTower(), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.First), Is.False,
"Branches are only selected when upgrading from tier three.");
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.GetTowerAtPad(pad).Tier, Is.EqualTo(2));
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.GetTowerAtPad(pad).Tier, Is.EqualTo(3));
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.Second), Is.True);
Defense25DTower tierFour = world.TowerManager.GetTowerAtPad(pad);
Assert.That(tierFour.Tier, Is.EqualTo(4));
Assert.That(tierFour.Branch, Is.EqualTo(DefenseTowerBranch.Second));
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.First), Is.False);
yield return null;
}
[UnityTest]
public IEnumerator RelicCannotBeGrantedOutsideACollectedPortalReward()
{
PassiveRelicId available = world.Loadout.GetAvailableRelics().First();
Assert.That(world.QueueFieldRelicRewardForCollectedPortal(0), Is.False);
Assert.That(world.HasPendingFieldRelicReward, Is.False);
Assert.That(world.ResolveFieldRelicChoice(available), Is.False);
Assert.That(world.Loadout.OwnsRelic(available), Is.False);
Assert.That(world.Paused, Is.False);
yield return null;
}
[UnityTest]
public IEnumerator PresentationPointerRegionsConsumeUiClicksButLeaveArenaPointerFree()
{
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
float scale = viewport.width / Defense25DView.Width;
Vector2 ScreenPoint(float x, float y) => new(viewport.x + x * scale,
Screen.height - (viewport.y + y * scale));
foreach (Rect control in world.GetPersistentDefenseUiHitRects())
Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(control.center.x, control.center.y)), Is.True,
$"Clicks on persistent HUD control {control} must not also operate the arena.");
Assert.That(world.SelectBuildPad(0), Is.True);
Rect details = world.GetSelectedPadDetailsRect();
Assert.That(details.width, Is.GreaterThan(0f));
Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(details.center.x, details.center.y)), Is.True,
"Clicks in the selected pad's contextual panel must not reach the arena.");
Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(320, 180)), Is.False,
"The central field remains available for aiming and movement.");
yield return null;
}
[UnityTest]
public IEnumerator ShowcaseModeUsesDedicatedEntryAndExitHooks()
{
int entered = 0;
int exited = 0;
world.BindValidationShowcase(() => { entered++; return true; }, () => exited++);
Assert.That(world.EnterValidationShowcase(), Is.True);
Assert.That(world.IsValidationShowcaseActive, Is.True);
Assert.That(world.EnterValidationShowcase(), Is.False);
Assert.That(entered, Is.EqualTo(1));
Assert.That(world.ExitValidationShowcase(), Is.True);
Assert.That(world.IsValidationShowcaseActive, Is.False);
Assert.That(exited, Is.EqualTo(1));
yield return null;
}
private static ComparisonSpriteFrames ReadCharacter()
{
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
$"Assets/_Project/Art/Characters/Slime/Slime_{suffix}.png")
.OfType<Sprite>()
.OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0)
.ToArray();
return new ComparisonSpriteFrames
{
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read("Attack"),
Hurt = Read("Hurt"), Death = Read("Death"),
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2655c2a00f04de2a5c26d1a7e69fe6a
@@ -0,0 +1,325 @@
using System.Collections;
using System.IO;
using System.Linq;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DComparisonShowcasePlayModeTests
{
private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
private Defense25DComparison world;
private float previousTimeScale;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
CompleteStartupArtifactSelection(world);
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator ShowcaseUsesSixRealTowerFamiliesAndStationarySkeletonsThenRestoresTheRun()
{
Defense25DTowerManager runManager = world.TowerManager;
runManager.AddCurrency(1000);
DefenseConstants tuning = GameplayConstants.Current.Defense;
Vector2 directPad = tuning.GetBuildPadPosition(0);
world.Player.ResetAt(directPad);
Physics.SyncTransforms();
Assert.That(runManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
Defense25DTower runDirect = runManager.GetTowerAtPad(0);
Vector2 summonPad = tuning.GetBuildPadPosition(1);
world.Player.ResetAt(summonPad);
Physics.SyncTransforms();
Assert.That(runManager.TryBuildTowerAtPad(1, DefenseTowerKind.Summon), Is.True);
Defense25DTower runSummonTower = runManager.GetTowerAtPad(1);
Defense25DSummon runSummon = world.Summons.First(summon => summon.Owner == runSummonTower);
Assert.That(runSummon, Is.Not.Null);
runSummon.Actor.ResetAt(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon));
Defense25DEnemy runEnemy = null;
for (int attempt = 0; attempt < GameplayConstants.Current.Defense.PortalEnemyCount * 2; attempt++)
{
Defense25DEnemy candidate = world.SpawnNextEnemy();
if (candidate == null) break;
if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate))
{
runEnemy = candidate;
break;
}
}
Assert.That(runEnemy, Is.Not.Null, "A non-immune lane must be available within the configured two-lane supply.");
runEnemy.Actor.ResetAt(runDirect.Position + Vector2.right * .7f);
Physics.SyncTransforms();
runDirect.StepParityAttacks(0f);
Transform runProjectile = world.transform.Find("Tower Projectile");
Assert.That(runProjectile, Is.Not.Null, "The existing run must have a live tower projectile before showcase entry.");
DefenseSpriteSequence projectileSequence = runProjectile.GetComponentInChildren<DefenseSpriteSequence>(true);
Assert.That(projectileSequence, Is.Not.Null);
Assert.That(projectileSequence.IsFinished, Is.False);
Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton"));
Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null);
Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0));
Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack"));
int runCurrency = runManager.ConstructionCurrency;
int supplied = world.Ledger.SuppliedCount;
int resolved = world.Ledger.ResolvedCount;
int kills = world.KillCurrency;
float baseHealth = world.BaseHealth;
float gameTime = world.GameTime;
Vector2 playerPosition = new(-4.5f, 0f);
world.Player.ResetAt(playerPosition);
Physics.SyncTransforms();
DefenseTowerKind[] kinds = DefenseTowerHitTestLayout.KindsByPad;
world.BindValidationShowcase(world.EnterTowerShowcase, world.ExitTowerShowcase);
Assert.That(world.EnterValidationShowcase(), Is.True);
Assert.That(world.IsTowerShowcaseSimulationActive, Is.True);
Assert.That(world.TowerManager, Is.Not.SameAs(runManager));
Assert.That(runManager.IsSuspended, Is.True);
Assert.That(runDirect.GetComponentInChildren<SpriteRenderer>(true).enabled, Is.False,
"The parked run tower must not render over the showcase tower on the shared pad.");
Assert.That(runSummon.gameObject.activeSelf, Is.False,
"Parked summon colliders and movement must not participate in the showcase.");
Assert.That(runProjectile.gameObject.activeInHierarchy, Is.False,
"A parked flight and its sprite sequence must not advance or expire during showcase time.");
Assert.That(runEnemy.gameObject.activeSelf, Is.False,
"Normal route enemies must be suspended while the isolated showcase runs.");
Assert.That(world.TowerShowcaseDummies.Count, Is.EqualTo(kinds.Length));
Assert.That(world.TowerManager.Towers.Count, Is.EqualTo(kinds.Length));
for (int i = 0; i < kinds.Length; i++)
{
Assert.That(world.TowerManager.GetTowerAtPad(i).Kind, Is.EqualTo(kinds[i]));
Assert.That(world.TowerManager.GetTowerAtPad(i).Tier, Is.EqualTo(1));
Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kinds[i]);
Assert.That(dummy, Is.Not.Null);
Assert.That(dummy.IsShowcaseDummy, Is.True);
Assert.That(dummy.IsAvailable, Is.True);
Assert.That(dummy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_Idle"));
Assert.That(world.IsTowerShowcaseTarget(kinds[i], dummy), Is.True);
Assert.That(world.IsTowerShowcaseTarget(kinds[(i + 1) % kinds.Length], dummy), Is.False);
Assert.That(world.GetTowerTargets(kinds[i]).Where(target => target.IsShowcaseDummy),
Is.EquivalentTo(new[] { dummy }), "Each family may query only its assigned showcase target.");
}
var dummyPositions = kinds.ToDictionary(kind => kind,
kind => world.GetTowerShowcaseDummy(kind).Actor.Position);
float evidenceDeadline = Time.realtimeSinceStartup + 20f;
while (Time.realtimeSinceStartup < evidenceDeadline
&& kinds.Where(kind => kind != DefenseTowerKind.Empowering)
.Any(kind => world.GetTowerShowcaseHitCount(kind) == 0))
yield return new WaitForFixedUpdate();
for (int i = 0; i < 150; i++) yield return new WaitForFixedUpdate();
Camera camera = world.View.Camera;
camera.Render();
Color32[] rendered = ReadPixels(world.View.Texture);
foreach (DefenseTowerKind kind in kinds)
{
Defense25DTower tower = world.TowerManager.TowersInConstructionOrder
.First(item => item.Kind == kind);
SpriteRenderer towerBody = tower.transform.Find("Tower billboard").GetComponent<SpriteRenderer>();
Assert.That(towerBody.sprite, Is.Not.Null, $"{kind} must render its authored tower body sprite.");
Assert.That(RendererContribution(camera, rendered, towerBody), Is.GreaterThan(0),
$"{kind} tower body must contribute pixels through the saved scene's renderer.");
Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kind);
Assert.That(RendererContribution(camera, rendered, dummy.Actor.Visual.Renderer), Is.GreaterThan(0),
$"The {kind} assigned Skeleton must contribute full-body sprite pixels.");
}
Assert.That(RendererContribution(camera, rendered, world.Player.Visual.Renderer), Is.GreaterThan(0),
"The saved-scene player sprite must remain visible in the showcase capture.");
CaptureTowerShowcase();
foreach (DefenseTowerKind kind in kinds.Where(kind => kind != DefenseTowerKind.Empowering))
{
Defense25DTower tower = world.TowerManager.TowersInConstructionOrder
.First(item => item.Kind == kind);
string summonState = kind == DefenseTowerKind.Summon
? string.Join("; ", world.Summons.Where(item => item.Owner == tower).Select(item =>
$"slot={item.SlotIndex} pos={item.Actor.Position} alive={item.Actor.IsAlive} " +
$"attack={item.IsAttacking} attacks={item.AttackEvents} moving={item.Actor.LastMove} " +
$"knockback={item.Actor.HasKnockback} elapsed={GetPrivateFloat(item, "attackElapsed"):0.000} " +
$"next={GetPrivateFloat(item, "nextAttack"):0.000} targetGap=" +
(tower.CommandTarget == null ? "none" : item.Actor.GapTo(tower.CommandTarget.Actor).ToString("0.000"))))
: string.Empty;
Assert.That(world.GetTowerShowcaseHitCount(kind), Is.GreaterThan(0),
$"No actual attack evidence for {kind}. command={tower.CommandTarget?.name ?? "none"}; " +
$"targets={world.GetTowerTargets(kind).Count()} gameTime={world.GameTime:0.000} " +
$"running={world.IsRunning} paused={world.Paused} operational={world.TowersOperational} " +
$"towerEnabled={tower.isActiveAndEnabled} summonCount={tower.SummonCount}/{tower.TargetSummonCount} {summonState}");
}
Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Direct), Is.GreaterThan(0f),
"The pad-4 Empowering tower must support the pad-0 Direct tower within the authored radius.");
Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Summon), Is.EqualTo(0f),
"The remote pad-1 Summon tower is outside the authored support radius.");
foreach (DefenseTowerKind kind in kinds)
Assert.That(world.GetTowerShowcaseDummy(kind).Actor.Position,
Is.EqualTo(dummyPositions[kind]), $"The {kind} Skeleton target must remain stationary.");
Defense25DEnemy respawnTarget = world.GetTowerShowcaseDummy(DefenseTowerKind.Direct);
float aliveDeadline = Time.realtimeSinceStartup + 8f;
while (!respawnTarget.IsAvailable && Time.realtimeSinceStartup < aliveDeadline)
yield return new WaitForFixedUpdate();
Assert.That(respawnTarget.IsAvailable, Is.True);
Defense25DTower directTower = world.TowerManager.GetTowerAtPad(0);
MethodInfo applyDamage = typeof(Defense25DTower).GetMethod("ApplyDamage",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(applyDamage, Is.Not.Null);
int popupCount = world.ActiveTowerDamagePopupCount;
Assert.That(applyDamage.Invoke(directTower,
new object[] { respawnTarget, respawnTarget.Actor.MaxHealth + 1f }), Is.EqualTo(true));
Assert.That(respawnTarget.Actor.CurrentHealth, Is.EqualTo(respawnTarget.Actor.MaxHealth),
"A showcase dummy respawns synchronously at full health after a lethal tower hit.");
Assert.That(world.ActiveTowerDamagePopupCount, Is.GreaterThan(popupCount),
"The lethal hit's actual damage popup must be recorded before immediate dummy respawn.");
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied));
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved));
Assert.That(world.KillCurrency, Is.EqualTo(kills));
Assert.That(world.TowerManager.ConstructionCurrency,
Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency));
Assert.That(respawnTarget.IsAvailable, Is.True,
"A dead tower-test dummy must be replaced immediately, not after the summon-unit timer.");
Assert.That(respawnTarget.Actor.Position,
Is.EqualTo(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Direct)));
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved));
Assert.That(world.KillCurrency, Is.EqualTo(kills));
Assert.That(world.ExitValidationShowcase(), Is.True);
Assert.That(world.IsTowerShowcaseSimulationActive, Is.False);
Assert.That(world.TowerManager, Is.SameAs(runManager));
Assert.That(runManager.IsSuspended, Is.False);
Assert.That(runDirect.GetComponentInChildren<SpriteRenderer>(true).enabled, Is.True);
Assert.That(runSummon.gameObject.activeSelf, Is.True);
Assert.That(runSummon.Actor.BodyCollider.enabled, Is.True);
Assert.That(runSummon.Actor.Visual.Renderer.enabled, Is.True);
Assert.That(runProjectile.gameObject.activeInHierarchy, Is.True);
Assert.That(projectileSequence.IsFinished, Is.False,
"The existing projectile must resume with its remaining flight after showcase exit.");
Assert.That(runEnemy.gameObject.activeSelf, Is.True,
"The previously active route Skeleton must resume after leaving the showcase.");
Assert.That(runEnemy.Actor.BodyCollider.enabled, Is.True,
"The resumed route Skeleton restores its collision footprint.");
Assert.That(runEnemy.Actor.Visual.Renderer.enabled, Is.True,
"The resumed route Skeleton restores its billboard.");
Assert.That(runManager.ConstructionCurrency, Is.EqualTo(runCurrency));
Assert.That(world.Player.Position, Is.EqualTo(playerPosition));
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied));
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved));
Assert.That(world.KillCurrency, Is.EqualTo(kills));
Assert.That(world.BaseHealth, Is.EqualTo(baseHealth));
Assert.That(world.GameTime, Is.EqualTo(gameTime).Within(.001f));
Assert.That(world.IsRunning, Is.True);
}
private static float GetPrivateFloat(Defense25DSummon summon, string field) =>
(float)typeof(Defense25DSummon).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)
.GetValue(summon);
private static int RendererContribution(Camera camera, Color32[] visible, Renderer renderer)
{
Vector3 screen = camera.WorldToScreenPoint(renderer.bounds.center);
Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane));
bool wasEnabled = renderer.enabled;
try
{
renderer.enabled = false;
camera.Render();
Color32[] hidden = ReadPixels(camera.targetTexture as RenderTexture);
return DifferentPixelsNear(visible, hidden, screen, 28);
}
finally
{
renderer.enabled = wasEnabled;
camera.Render();
}
}
private static int DifferentPixelsNear(Color32[] a, Color32[] b, Vector3 center, int radius)
{
int count = 0, cx = Mathf.RoundToInt(center.x), cy = Mathf.RoundToInt(center.y);
for (int y = -radius; y <= radius; y++)
for (int x = -radius; x <= radius; x++)
{
int px = cx + x, py = cy + y;
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
if (!a[py * Defense25DView.Width + px].Equals(b[py * Defense25DView.Width + px])) count++;
}
return count;
}
private static Color32[] ReadPixels(RenderTexture texture)
{
RenderTexture previous = RenderTexture.active;
RenderTexture.active = texture;
Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0);
image.Apply();
Color32[] pixels = image.GetPixels32();
RenderTexture.active = previous;
Object.DestroyImmediate(image);
return pixels;
}
private static void CompleteStartupArtifactSelection(Defense25DComparison comparison)
{
var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(choose, Is.Not.Null);
for (int i = 0; comparison.Paused && i < 3; i++)
Assert.That(choose.Invoke(comparison, new object[] { 0 }), Is.EqualTo(true));
Assert.That(comparison.Paused, Is.False,
"The showcase fixture must complete the authored startup artifact choices before entering the run.");
}
private void CaptureTowerShowcase()
{
string directory = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
Directory.CreateDirectory(directory);
world.View.Camera.Render();
RenderTexture previous = RenderTexture.active;
RenderTexture.active = world.View.Texture;
Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0);
image.Apply();
RenderTexture.active = previous;
File.WriteAllBytes(Path.Combine(directory, "20-tower-showcase-six-families-640x360.png"),
image.EncodeToPNG());
Object.Destroy(image);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9809ce461dde4db6a6fac9d0626bbd42
@@ -0,0 +1,144 @@
using System.Collections;
using System.IO;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DGroundAnchorPlayModeTests
{
private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
private Scene ownedTestScene;
[UnityTest]
public IEnumerator ActorVisualGroundAnchorsMatchInterpolatedColliderCentersWithoutCylinderShadows()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
ownedTestScene = SceneManager.GetActiveScene();
Defense25DComparison world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
world.ShowBoundaries = true;
world.SetPaused(true);
Vector2 origin = new(0f, -2f);
world.Player.ResetAt(origin + Vector2.left * 4f);
world.Enemies[0].Spawn(origin + Vector2.left * 2f);
Defense25DActor baseSlime = CreateSummon(world, "Anchor Slime", origin, 1, DefenseTowerBranch.First);
Defense25DActor giantSlime = CreateSummon(world, "Anchor Giant Slime", origin + Vector2.right * 2f, 4, DefenseTowerBranch.First);
Defense25DActor lavaSlime = CreateSummon(world, "Anchor Lava Slime", origin + Vector2.right * 4f, 4, DefenseTowerBranch.Second);
Physics.SyncTransforms();
yield return null;
yield return null;
Defense25DActor[] actors = { world.Player, world.Enemies[0].Actor, baseSlime, giantSlime, lavaSlime };
Assert.That(giantSlime.Visual.FootVisualOffset, Is.Zero,
"The authored Giant Slime ground point (50,60) maps to its 128x128 center pivot (64,64), so it needs no local foot correction.");
Assert.That(baseSlime.Visual.FootVisualOffset, Is.EqualTo(world.Settings.SummonFootOffset).Within(.0001f));
Assert.That(lavaSlime.Visual.FootVisualOffset,
Is.EqualTo(world.Settings.SummonFootOffset * (lavaSlime.Visual.VisualScale / world.Settings.SummonVisualScale)).Within(.0001f));
foreach (Defense25DActor actor in actors)
{
Vector2 renderCenter = Defense25DActor.Ground(actor.transform.position) + actor.Offset;
Vector3 expectedAnchor = Defense25DActor.OnGround(renderCenter, world.Settings.GroundY);
Vector3 colliderCenter = actor.BodyCollider.transform.TransformPoint(actor.BodyCollider.center);
Assert.That(Vector2.Distance(actor.Center, Defense25DActor.Ground(actor.Body.position)
+ actor.Offset + actor.BodyCenterOffset), Is.LessThan(.001f),
$"{actor.Kind} combat center must use the fixed body-to-foot offset.");
Assert.That(Vector3.Distance(actor.Visual.FootPosition, expectedAnchor), Is.LessThan(.001f),
$"{actor.Kind} ground anchor must remain independent from the body-center correction.");
Assert.That(Vector2.Distance(new Vector2(colliderCenter.x, colliderCenter.z), renderCenter + actor.BodyCenterOffset),
Is.LessThan(.001f), $"{actor.Kind} capsule center must use its fixed body-to-foot offset from the visual ground anchor.");
Assert.That(actor.BodyCollider.radius, Is.EqualTo(actor.Radius).Within(.0001f));
Assert.That(actor.BodyCollider.height, Is.GreaterThanOrEqualTo(2f * actor.Radius));
Assert.That(actor.BodyCollider.center.x, Is.EqualTo(actor.Offset.x + actor.BodyCenterOffset.x).Within(.0001f));
Assert.That(actor.BodyCollider.center.z, Is.EqualTo(actor.Offset.y + actor.BodyCenterOffset.y).Within(.0001f));
foreach (Transform child in actor.GetComponentsInChildren<Transform>(true))
Assert.That(child.name, Is.Not.EqualTo("Ground shadow"),
$"{actor.Kind} must not get the extra runtime cylinder shadow.");
SpriteRenderer renderer = actor.Visual.Renderer;
Assert.That(renderer, Is.Not.Null);
Assert.That(renderer.sprite, Is.Not.Null,
$"{actor.Kind} retains its existing sprite, including any authored shadow pixels.");
float scaledCorrection = actor.Visual.FootVisualOffset
* (renderer.transform.localScale.y / actor.Visual.VisualScale);
float renderedCorrection = Vector3.Dot(renderer.transform.position - expectedAnchor,
world.View.transform.up);
float halfPixelWorld = world.View.Camera.orthographicSize / Defense25DView.Height;
Assert.That(renderedCorrection, Is.EqualTo(-scaledCorrection).Within(halfPixelWorld + .0001f),
$"{actor.Kind} local billboard-foot correction must scale with its rendered scale.");
if (actor == giantSlime)
{
Vector3 pivotScreen = world.View.Camera.WorldToScreenPoint(renderer.transform.position);
Vector3 anchorScreen = world.View.Camera.WorldToScreenPoint(expectedAnchor);
Assert.That(Mathf.Abs(pivotScreen.y - anchorScreen.y), Is.LessThanOrEqualTo(1f),
"Giant Slime's authored center pivot must project onto its ground anchor within one render pixel.");
}
}
string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT");
if (string.IsNullOrWhiteSpace(output))
output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures");
Directory.CreateDirectory(output);
string capturePath = Path.Combine(output, "ground-anchor-actors-after.png");
world.View.Camera.Render();
Texture2D capture = ReadPixels(world.View.Texture);
File.WriteAllBytes(capturePath, capture.EncodeToPNG());
Object.DestroyImmediate(capture);
Assert.That(File.Exists(capturePath), Is.True);
Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(0));
}
[UnityTearDown]
public IEnumerator UnloadOwnedTestScene()
{
Time.timeScale = 1f;
if (ownedTestScene.IsValid() && ownedTestScene.isLoaded)
{
Scene cleanup = SceneManager.CreateScene("GroundAnchorTestCleanup");
SceneManager.SetActiveScene(cleanup);
AsyncOperation unload = SceneManager.UnloadSceneAsync(ownedTestScene);
if (unload != null) yield return unload;
}
ownedTestScene = default;
}
private static Defense25DActor CreateSummon(Defense25DComparison world, string name,
Vector2 position, int tier, DefenseTowerBranch branch)
{
GameObject root = new(name);
root.transform.SetParent(world.transform, false);
root.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY);
Defense25DActor actor = root.AddComponent<Defense25DActor>();
actor.Configure(world, ComparisonActorKind.Summon);
actor.Visual.ConfigureSummonArt(tier, branch);
actor.ResetAt(position);
return actor;
}
private static Texture2D ReadPixels(RenderTexture source)
{
RenderTexture previous = RenderTexture.active;
RenderTexture.active = source;
Texture2D copy = new(source.width, source.height, TextureFormat.RGBA32, false);
copy.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0);
copy.Apply();
RenderTexture.active = previous;
return copy;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c853c82d04b4d999d32a036c7c20d1b
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEngine;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DLoadoutPlayModeTests
{
private readonly List<ActiveArtifactDefinition> definitions = new();
[TearDown]
public void TearDown()
{
foreach (ActiveArtifactDefinition definition in definitions)
Object.DestroyImmediate(definition);
definitions.Clear();
}
[Test]
public void SourceCatalogsExposeAllSixActiveEffectsAndTwentyRelics()
{
Assert.That(Enum.GetValues(typeof(ActiveArtifactEffect)).Length, Is.EqualTo(6));
Assert.That(Enum.GetValues(typeof(PassiveRelicId)).Length, Is.EqualTo(20));
Assert.That(PassiveRelicRules.GetAvailableRelics(Array.Empty<PassiveRelicId>()).Length,
Is.EqualTo(20));
}
[Test]
public void EveryActiveEffectDispatchesAndEveryRelicCanBeAcquired()
{
Defense25DLoadout loadout = NewLoadout(1000f, 6, 6);
List<ActiveArtifactEffect> activated = new();
foreach (ActiveArtifactEffect effect in Enum.GetValues(typeof(ActiveArtifactEffect)))
{
ActiveArtifactDefinition definition = Definition(effect, 1f, 2f);
Assert.That(loadout.TryAddArtifact(definition), Is.True, effect.ToString());
Assert.That(loadout.Select(loadout.OwnedArtifactCount - 1), Is.True);
loadout.ExecuteArtifact = use =>
{
activated.Add(use.Effect);
return true;
};
Assert.That(loadout.TryUse(false), Is.True, effect.ToString());
}
Assert.That(activated, Is.EquivalentTo(Enum.GetValues(typeof(ActiveArtifactEffect))));
foreach (PassiveRelicId relic in Enum.GetValues(typeof(PassiveRelicId)))
Assert.That(loadout.TryAcquireRelic(relic), Is.True, relic.ToString());
Assert.That(loadout.OwnedRelicCount, Is.EqualTo(20));
}
[Test]
public void LoadoutEnforcesArtifactColorAndCapacityRules()
{
Defense25DLoadout loadout = NewLoadout(100f, 3, 1);
ActiveArtifactDefinition dash = Definition(ActiveArtifactEffect.Dash, 10f, 30f);
ActiveArtifactDefinition cyclone = Definition(ActiveArtifactEffect.Cyclone, 10f, 30f);
ActiveArtifactDefinition pulse = Definition(ActiveArtifactEffect.Pulse, 10f, 30f);
Assert.That(loadout.TryAddArtifact(dash), Is.True);
Assert.That(loadout.TryAddArtifact(cyclone), Is.False);
Assert.That(loadout.TryAddArtifact(pulse), Is.True);
Assert.That(loadout.OwnedArtifactCount, Is.EqualTo(2));
}
[Test]
public void ChargeReleaseUsesDefinitionCostAndFailedExecutionKeepsGauge()
{
Defense25DLoadout loadout = NewLoadout(80f, 3, 1);
ActiveArtifactDefinition definition = Definition(ActiveArtifactEffect.Pulse, 20f, 60f, 2f);
Assert.That(loadout.TryAddArtifact(definition), Is.True);
loadout.Select(0);
loadout.ExecuteArtifact = _ => false;
Assert.That(loadout.BeginCharge(), Is.True);
loadout.AdvanceCharge(1f);
Assert.That(loadout.ChargeProgress, Is.EqualTo(.5f).Within(.001f));
Assert.That(loadout.ReleaseCharge(), Is.False);
Assert.That(loadout.Gauge, Is.EqualTo(80f));
Assert.That(loadout.IsCharging, Is.False);
loadout.ExecuteArtifact = _ => true;
Assert.That(loadout.TryUse(false), Is.True);
Assert.That(loadout.Gauge, Is.EqualTo(60f));
}
[Test]
public void RunResetClearsOwnedStateAndRestoresStartingGauge()
{
Defense25DLoadout loadout = NewLoadout(25f, 3, 1);
Assert.That(loadout.TryAddArtifact(Definition(ActiveArtifactEffect.Dash, 10f, 30f)), Is.True);
Assert.That(loadout.TryAcquireRelic(PassiveRelicId.TwinHornTalisman), Is.True);
loadout.ResetRun();
Assert.That(loadout.OwnedArtifactCount, Is.Zero);
Assert.That(loadout.OwnedRelicCount, Is.Zero);
Assert.That(loadout.Gauge, Is.EqualTo(25f));
}
[Test]
public void RelicOfferPrerequisitesAndTriggerDispatchAreEnforced()
{
Defense25DLoadout loadout = NewLoadout(50f, 3, 1);
int triggers = 0;
loadout.DispatchRelicTrigger = _ => triggers++;
Assert.That(loadout.TryAcquireRelic(PassiveRelicId.EmberCarrier), Is.False);
Assert.That(loadout.TryAcquireRelic(PassiveRelicId.EmberWick), Is.True);
Assert.That(loadout.TryAcquireRelic(PassiveRelicId.EmberCarrier), Is.True);
loadout.NotifyRelicTrigger(PassiveRelicId.EmberCarrier);
loadout.NotifyRelicTrigger(PassiveRelicId.BloodFang);
Assert.That(triggers, Is.EqualTo(1));
}
private static Defense25DLoadout NewLoadout(float startingGauge, int maxArtifacts, int maxPerColor) =>
new(startingGauge, 100f, maxArtifacts, maxPerColor);
private ActiveArtifactDefinition Definition(
ActiveArtifactEffect effect, float normalCost, float chargedCost, float chargeDuration = .8f)
{
ActiveArtifactDefinition definition = ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
definition.Configure(effect.ToString(), effect.ToString(), "*", effect,
DamageTag.Collision, Color.white, Color.white, normalCost, chargedCost,
chargeDuration, 1f, 2f, 1f, 2f, 1f, 2f);
definitions.Add(definition);
return definition;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: db873143f6314ed6ba44eee6effec287
@@ -0,0 +1,334 @@
using System.Collections;
using System.IO;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DPixelAlignmentPlayModeTests
{
private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
[UnityTest]
public IEnumerator SavedSceneIdleUsesIntegerProjectedSourceTexels()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
Defense25DComparison world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
world.SetPaused(true);
world.Player.ResetAt(world.Settings.PlayerSpawn);
yield return null;
SpriteRenderer renderer = world.Player.Visual.Renderer;
Sprite source = world.Settings.PlayerArt.Idle[0];
Assert.That(source, Is.Not.Null);
renderer.sprite = source;
world.View.Camera.Render();
float unitsPerTexel = 1f / source.pixelsPerUnit;
Vector3 texelStep = renderer.transform.TransformVector(Vector3.up * unitsPerTexel);
Vector3 a = world.View.Camera.WorldToScreenPoint(renderer.transform.position);
Vector3 b = world.View.Camera.WorldToScreenPoint(renderer.transform.position + texelStep);
float projectedTexelPitch = Vector2.Distance(a, b);
string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT");
if (string.IsNullOrWhiteSpace(output))
output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures");
Directory.CreateDirectory(output);
string capturePath = Path.Combine(output, "player-idle-after.png");
Texture2D captured = ReadPixels(world.View.Texture);
File.WriteAllBytes(capturePath, captured.EncodeToPNG());
Object.DestroyImmediate(captured);
Texture2D sourceFrame = ReadSpriteSource(source);
File.WriteAllBytes(Path.Combine(output, "player-idle-source-after.png"),
sourceFrame.EncodeToPNG());
Object.DestroyImmediate(sourceFrame);
File.WriteAllText(Path.Combine(output, "player-idle-after.txt"),
$"camera={world.View.Camera.pixelWidth}x{world.View.Camera.pixelHeight}; "
+ $"orthographicSize={world.View.Camera.orthographicSize:R}; "
+ $"pixelsPerUnit={source.pixelsPerUnit:R}; "
+ $"authoredScale={world.Settings.PlayerVisualScale:R}; "
+ $"visualScale={renderer.transform.lossyScale.y:R}; "
+ $"projectedTexelPitch={projectedTexelPitch:R}; playerPosition={world.Player.Position}; "
+ $"sprite={source.name}");
Assert.That(projectedTexelPitch, Is.EqualTo(Mathf.Round(projectedTexelPitch)).Within(.001f),
$"A stationary source texel spans {projectedTexelPitch:R} internal pixels. "
+ $"The captured AFTER render is {capturePath}.");
Assert.That(source.texture.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(renderer.transform.localScale.y, Is.EqualTo(1.1555555f).Within(.001f),
"Only the player's visual scale is quantized; the authored collider and camera framing stay fixed.");
}
[UnityTest]
public IEnumerator FeedbackTintSurvivesSpriteLateUpdateAndCameraOffsetIsComposedInPixels()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
Defense25DComparison world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
world.SetPaused(true);
Color tint = new(.6f, .8f, .9f, .35f);
world.Player.Visual.SetFeedbackTint(tint);
yield return null;
Assert.That(world.Player.Visual.Renderer.color, Is.EqualTo(tint));
SpriteRenderer playerRenderer = world.Player.Visual.Renderer;
Assert.That(playerRenderer.sharedMaterial, Is.Not.SameAs(world.Settings.CutoutMaterial));
Assert.That(playerRenderer.sharedMaterial.GetColor("_BaseColor"), Is.EqualTo(Color.white));
Assert.That(playerRenderer.sharedMaterial.GetFloat("_Cutoff"), Is.LessThan(.35f));
Camera camera = world.View.Camera;
CameraClearFlags savedClear = camera.clearFlags;
Color savedBackground = camera.backgroundColor;
int savedMask = camera.cullingMask;
Renderer[] renderers = Object.FindObjectsByType<Renderer>(FindObjectsSortMode.None);
bool[] rendererStates = new bool[renderers.Length];
for (int i = 0; i < renderers.Length; i++)
{
rendererStates[i] = renderers[i].enabled;
renderers[i].enabled = renderers[i] == playerRenderer;
}
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = Color.clear;
camera.cullingMask = -1;
camera.Render();
Texture2D blinkCapture = ReadPixels(world.View.Texture);
int visiblePixels = 0;
foreach (Color32 pixel in blinkCapture.GetPixels32())
if (pixel.a > 0 && (pixel.r > 0 || pixel.g > 0 || pixel.b > 0)) visiblePixels++;
string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT");
if (string.IsNullOrWhiteSpace(output))
output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures");
Directory.CreateDirectory(output);
string blinkCapturePath = Path.Combine(output, "player-blink-alpha-035.png");
File.WriteAllBytes(blinkCapturePath, blinkCapture.EncodeToPNG());
Object.DestroyImmediate(blinkCapture);
camera.clearFlags = savedClear;
camera.backgroundColor = savedBackground;
camera.cullingMask = savedMask;
for (int i = 0; i < renderers.Length; i++)
if (renderers[i] != null) renderers[i].enabled = rendererStates[i];
Assert.That(visiblePixels, Is.GreaterThan(0),
$"The .35 alpha player blink must render through the configured cutout cutoff; capture={blinkCapturePath}.");
world.Player.Visual.ClearFeedbackTint();
yield return null;
Assert.That(world.Player.Visual.Renderer.color, Is.EqualTo(Color.white));
Assert.That(playerRenderer.sharedMaterial, Is.SameAs(world.Settings.CutoutMaterial));
Vector3 target = world.Player.Visual.Renderer.transform.position;
Vector3 before = camera.WorldToScreenPoint(target);
world.View.SetFeedbackCameraOffset(new Vector2(3f, -2f));
yield return new WaitForEndOfFrame();
Vector3 after = camera.WorldToScreenPoint(target);
Assert.That(after.x - before.x, Is.EqualTo(-3f).Within(.02f));
Assert.That(after.y - before.y, Is.EqualTo(2f).Within(.02f));
SpriteRenderer shakenPlayer = world.Player.Visual.Renderer;
Vector3 shakenPivot = camera.WorldToScreenPoint(shakenPlayer.transform.position);
float shakenPitch = camera.targetTexture.height / (2f * camera.orthographicSize)
* shakenPlayer.transform.lossyScale.x / shakenPlayer.sprite.pixelsPerUnit;
float flippedPivotX = shakenPlayer.flipX
? shakenPlayer.sprite.rect.width - shakenPlayer.sprite.pivot.x : shakenPlayer.sprite.pivot.x;
float shakenLeft = shakenPivot.x - flippedPivotX * shakenPitch;
float shakenBottom = shakenPivot.y - shakenPlayer.sprite.pivot.y * shakenPitch;
Assert.That(shakenLeft, Is.EqualTo(Mathf.Round(shakenLeft)).Within(.001f),
$"Player source-pixel edges must remain aligned after camera shake offset; pivot={shakenPivot}.");
Assert.That(shakenBottom, Is.EqualTo(Mathf.Round(shakenBottom)).Within(.001f),
$"Player source-pixel edges must remain aligned after camera shake offset; pivot={shakenPivot}.");
world.View.ClearFeedbackCameraOffset();
yield return null;
Vector3 cleared = camera.WorldToScreenPoint(target);
Assert.That(cleared.x, Is.EqualTo(before.x).Within(.02f));
Assert.That(cleared.y, Is.EqualTo(before.y).Within(.02f));
}
[UnityTest]
public IEnumerator SpritePixelEdgesStayOnTheSameGridAtFractionalGroundPositions()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
Defense25DComparison world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
world.SetPaused(true);
SpriteRenderer renderer = world.Player.Visual.Renderer;
renderer.sprite = world.Settings.PlayerArt.Idle[0];
Vector2[] positions = { new(-4.873f, -2.123f), new(-4.811f, -2.067f) };
foreach (Vector2 position in positions)
{
foreach (float facing in new[] { 1f, -1f })
{
world.Player.ResetAt(position);
world.Player.Facing = new Vector2(facing, 0f);
// A normal yield resumes before LateUpdate; observe the rendered transform afterwards.
yield return new WaitForEndOfFrame();
Vector3 projectedPivot = world.View.Camera.WorldToScreenPoint(renderer.transform.position);
float pitch = world.View.Camera.targetTexture.height
/ (2f * world.View.Camera.orthographicSize)
* renderer.transform.lossyScale.x / renderer.sprite.pixelsPerUnit;
float pivotX = renderer.flipX
? renderer.sprite.rect.width - renderer.sprite.pivot.x : renderer.sprite.pivot.x;
float pixelOriginX = projectedPivot.x - pivotX * pitch;
float pixelOriginY = projectedPivot.y - renderer.sprite.pivot.y * pitch;
Assert.That(pixelOriginX, Is.EqualTo(Mathf.Round(pixelOriginX)).Within(.001f),
$"Horizontal source-pixel edges must snap at ground position {position}, facing {facing}; pivot={projectedPivot}.");
Assert.That(pixelOriginY, Is.EqualTo(Mathf.Round(pixelOriginY)).Within(.001f),
$"Vertical source-pixel edges must snap at ground position {position}, facing {facing}; pivot={projectedPivot}.");
}
}
}
[UnityTest]
public IEnumerator FacingFlipMirrorsTheRenderedIdleAtTheSameFrameAndPosition()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
Defense25DComparison world = Object.FindAnyObjectByType<Defense25DComparison>();
Assert.That(world, Is.Not.Null);
yield return null;
world.AutoSpawn = false;
world.ReadInput = false;
world.SetPaused(true);
Vector2 position = new(-4.873f, -2.123f);
world.Player.ResetAt(position);
world.Player.Facing = Vector2.right;
SpriteRenderer player = world.Player.Visual.Renderer;
player.sprite = world.Settings.PlayerArt.Idle[0];
Renderer[] renderers = Object.FindObjectsByType<Renderer>(FindObjectsSortMode.None);
bool[] rendererStates = new bool[renderers.Length];
for (int i = 0; i < renderers.Length; i++)
{
rendererStates[i] = renderers[i].enabled;
renderers[i].enabled = renderers[i] == player;
}
Camera camera = world.View.Camera;
CameraClearFlags savedClear = camera.clearFlags;
Color savedBackground = camera.backgroundColor;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = Color.clear;
yield return new WaitForEndOfFrame();
Assert.That(player.sprite.name, Is.EqualTo(world.Settings.PlayerArt.Idle[0].name));
Texture2D right = CaptureCamera(camera, world.View.Texture);
world.Player.Facing = Vector2.left;
yield return new WaitForEndOfFrame();
Assert.That(player.sprite.name, Is.EqualTo(world.Settings.PlayerArt.Idle[0].name));
Texture2D left = CaptureCamera(camera, world.View.Texture);
string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT");
if (string.IsNullOrWhiteSpace(output))
output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures");
Directory.CreateDirectory(output);
string rightPath = Path.Combine(output, "player-idle-facing-right.png");
string leftPath = Path.Combine(output, "player-idle-facing-left.png");
File.WriteAllBytes(rightPath, right.EncodeToPNG());
File.WriteAllBytes(leftPath, left.EncodeToPNG());
int directDifference = Difference(right, left, false);
int mirroredDifference = Difference(right, left, true);
Object.DestroyImmediate(right);
Object.DestroyImmediate(left);
camera.clearFlags = savedClear;
camera.backgroundColor = savedBackground;
for (int i = 0; i < renderers.Length; i++)
if (renderers[i] != null) renderers[i].enabled = rendererStates[i];
Assert.That(player.flipX, Is.True, "Left facing must drive the SpriteRenderer flip state.");
Assert.That(directDifference, Is.GreaterThan(0), "The authored idle frame must be asymmetric.");
Assert.That(mirroredDifference, Is.LessThan(directDifference),
$"The actual left-facing capture must mirror the right-facing render; captures={rightPath}, {leftPath}.");
}
private static Texture2D ReadPixels(RenderTexture source)
{
RenderTexture previous = RenderTexture.active;
RenderTexture.active = source;
Texture2D copy = new(source.width, source.height, TextureFormat.RGBA32, false);
copy.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0);
copy.Apply();
RenderTexture.active = previous;
return copy;
}
private static Texture2D CaptureCamera(Camera camera, RenderTexture source)
{
camera.Render();
return ReadPixels(source);
}
private static int Difference(Texture2D first, Texture2D second, bool mirrorSecond)
{
int width = first.width;
int height = first.height;
Color32[] a = first.GetPixels32();
Color32[] b = second.GetPixels32();
int left = width, right = -1, bottom = height, top = -1;
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
if (a[y * width + x].a > 0 || b[y * width + x].a > 0)
{
left = Mathf.Min(left, x); right = Mathf.Max(right, x);
bottom = Mathf.Min(bottom, y); top = Mathf.Max(top, y);
}
Assert.That(right, Is.GreaterThanOrEqualTo(left), "Both captures should contain the player.");
int difference = 0;
for (int y = bottom; y <= top; y++)
for (int x = left; x <= right; x++)
{
Color32 ca = a[y * width + x];
Color32 cb = b[y * width + (mirrorSecond ? left + right - x : x)];
difference += Mathf.Abs(ca.r - cb.r) + Mathf.Abs(ca.g - cb.g)
+ Mathf.Abs(ca.b - cb.b) + Mathf.Abs(ca.a - cb.a);
}
return difference;
}
private static Texture2D ReadSpriteSource(Sprite sprite)
{
Rect rect = sprite.textureRect;
int width = Mathf.RoundToInt(rect.width);
int height = Mathf.RoundToInt(rect.height);
RenderTexture previous = RenderTexture.active;
RenderTexture target = RenderTexture.GetTemporary(width, height, 0,
RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
RenderTexture.active = target;
GL.PushMatrix();
GL.LoadPixelMatrix(0, width, 0, height);
GL.Clear(true, true, Color.clear);
Rect uv = new(rect.x / sprite.texture.width, rect.y / sprite.texture.height,
rect.width / sprite.texture.width, rect.height / sprite.texture.height);
Graphics.DrawTexture(new Rect(0, 0, width, height), sprite.texture, uv, 0, 0, 0, 0);
GL.PopMatrix();
Texture2D copy = new(width, height, TextureFormat.RGBA32, false);
copy.ReadPixels(new Rect(0, 0, width, height), 0, 0);
copy.Apply();
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(target);
return copy;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f7940a39adb9442e8ee18b9d93abcc7e
@@ -0,0 +1,180 @@
using System.Collections;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DPlayerArtPlayModeTests
{
private const string SettingsPath =
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private Defense25DPlayerArt playerArt;
[UnitySetUp]
public IEnumerator SetUp()
{
settings = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
settings.PlayerArt = ReadCharacter("Swordsman", "Swordsman", "BumpAttack-v1");
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
world = new GameObject("2.5D player art test world")
.AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
playerArt = world.Player.gameObject.AddComponent<Defense25DPlayerArt>();
playerArt.Bind(world);
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
yield return null;
}
[UnityTest]
public IEnumerator EquipmentSprite_UsesMatchingExistingFramesForEachPaletteAndRestores()
{
ActiveArtifactEffect[] effects =
{
ActiveArtifactEffect.Dash,
ActiveArtifactEffect.Pulse,
ActiveArtifactEffect.ChainLightning,
};
SpriteRenderer body = world.Player.Visual.Renderer;
for (int i = 0; i < effects.Length; i++)
{
ActiveArtifactDefinition definition = world.Loadout.FindArtifact(effects[i]);
Assert.That(definition, Is.Not.Null, effects[i].ToString());
Assert.That(world.Loadout.TryAddArtifact(definition), Is.True);
Assert.That(world.Loadout.Select(i), Is.True);
yield return null;
Sprite tinted = body.sprite;
Assert.That(tinted, Is.Not.Null);
Assert.That(tinted.texture.name,
Is.EqualTo("Swordsman_Idle-" + definition.ArtifactColor + "-v1"));
Sprite original = settings.PlayerArt.Idle.FirstOrDefault(
frame => frame.rect == tinted.rect);
Assert.That(original, Is.Not.Null);
Assert.That(tinted.rect, Is.EqualTo(original.rect));
Assert.That(tinted.pivot, Is.EqualTo(original.pivot));
Assert.That(tinted.pixelsPerUnit, Is.EqualTo(original.pixelsPerUnit));
playerArt.ResetVisuals();
Assert.That(body.sprite.texture.name, Is.EqualTo("Swordsman_Idle"));
}
}
[UnityTest]
public IEnumerator PrecoloredSequenceFrame_IsResolvedToBaseForSelectedPalette()
{
Sprite[] blueFrames = Resources.LoadAll<Sprite>(
"Artifacts/ThreeColor-v1/Player/Swordsman_Idle-Blue-v1");
Sprite blueFrame = blueFrames.FirstOrDefault(frame =>
settings.PlayerArt.Idle.Any(original => original.rect == frame.rect));
Assert.That(blueFrame, Is.Not.Null);
ActiveArtifactDefinition red = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse);
Assert.That(red, Is.Not.Null);
Assert.That(world.Loadout.TryAddArtifact(red), Is.True);
Assert.That(world.Loadout.Select(0), Is.True);
world.Player.Visual.Renderer.sprite = blueFrame;
yield return null;
Sprite recolored = world.Player.Visual.Renderer.sprite;
Assert.That(recolored.texture.name, Is.EqualTo("Swordsman_Idle-Red-v1"));
Assert.That(recolored.rect, Is.EqualTo(blueFrame.rect));
Assert.That(recolored.rect, Is.EqualTo(settings.PlayerArt.Idle
.First(original => original.rect == blueFrame.rect).rect));
playerArt.ResetVisuals();
Assert.That(world.Player.Visual.Renderer.sprite.texture.name, Is.EqualTo("Swordsman_Idle"));
}
[UnityTest]
public IEnumerator PrecoloredArtifactUseFrame_UsesBasePaletteAndGuardMaskKeys()
{
ActiveArtifactDefinition red = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse);
Assert.That(red, Is.Not.Null);
Assert.That(world.Loadout.TryAddArtifact(red), Is.True);
Assert.That(world.Loadout.Select(0), Is.True);
Sprite sequenceFrame = Resources.LoadAll<Sprite>(
"Artifacts/ThreeColor-v1/Player/Swordsman_ArtifactUse-v1-Blue-v1").First();
SpriteRenderer body = world.Player.Visual.Renderer;
body.sprite = sequenceFrame;
world.Player.Visual.enabled = false;
Assert.That(world.Player.TryActivateGuard(), Is.True);
yield return null;
SpriteRenderer outline = body.transform.Find("Player Guard Outline")
.GetComponent<SpriteRenderer>();
Assert.That(outline.enabled, Is.True);
Assert.That(outline.sprite.texture.name,
Is.EqualTo("Swordsman_ArtifactUse-v1-outline"));
Assert.That(outline.sprite.rect, Is.EqualTo(sequenceFrame.rect));
Assert.That(body.sprite.texture.name,
Is.EqualTo("Swordsman_ArtifactUse-v1-Red-v1"));
Assert.That(body.sprite.rect, Is.EqualTo(sequenceFrame.rect));
}
[UnityTest]
public IEnumerator DisabledWorld_DoesNotReapplyPaletteVisuals()
{
ActiveArtifactDefinition red = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse);
Assert.That(world.Loadout.TryAddArtifact(red), Is.True);
Assert.That(world.Loadout.Select(0), Is.True);
SpriteRenderer body = world.Player.Visual.Renderer;
Sprite original = settings.PlayerArt.Idle[0];
body.sprite = original;
world.enabled = false;
yield return null;
Assert.That(body.sprite, Is.SameAs(original));
world.enabled = true;
}
[UnityTest]
public IEnumerator UnguardedPlayer_HasNoOutlineAndResetRestoresSourceSprite()
{
SpriteRenderer body = world.Player.Visual.Renderer;
Assert.That(body.transform.Find("Player Guard Outline"), Is.Null,
"The art component has no local guard-state substitute; it follows the actor state.");
yield return null;
playerArt.ResetVisuals();
Assert.That(body.sprite.texture.name, Is.EqualTo("Swordsman_Idle"));
playerArt.enabled = false;
Assert.That(body.sprite.texture.name, Is.EqualTo("Swordsman_Idle"));
}
private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack)
{
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
.OfType<Sprite>()
.OrderBy(sprite => int.TryParse(
sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0)
.ToArray();
return new ComparisonSpriteFrames
{
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack),
Hurt = Read("Hurt"), Death = Read("Death"),
};
}
}
}
@@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 2b492f677ef5445a979e91e4200fe965
labels:
- Test
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,172 @@
using System.Collections;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DRelicReplayPlayModeTests
{
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private GameplayConstants originalConstants;
private GameplayConstants testConstants;
private PassiveRelicConstants testRelicTuning;
private Defense25DComparisonSettings settings;
private Defense25DComparison world;
private Defense25DEnemy enemy;
private ActiveArtifactDefinition phoenix;
private int ordinaryHits;
private int replayHits;
private int ordinaryHitFrame = -1;
private int replayHitFrame = -1;
private float previousTimeScale;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
ordinaryHits = replayHits = 0;
ordinaryHitFrame = replayHitFrame = -1;
originalConstants = GameplayConstants.Current;
testConstants = Object.Instantiate(originalConstants);
testRelicTuning = Object.Instantiate(originalConstants.PassiveRelics);
testRelicTuning.ResonanceEchoChance = 1f;
testRelicTuning.ResonantCrystalChance = 1f;
testRelicTuning.ResonantCrystalDelaySeconds = .1f;
typeof(GameplayConstants).GetField("passiveRelics", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(testConstants, testRelicTuning);
GameplayConstants.SetCurrentForTests(testConstants);
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
world = new GameObject("Relic replay integration world").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
world.Player.ResetAt(Vector2.zero);
phoenix = world.Loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
Assert.That(world.Loadout.TryAddArtifact(phoenix), Is.True);
testRelicTuning.ResonantCrystalDelaySeconds = .5f;
enemy = world.Enemies[0];
enemy.Actor.SetEnemyMaximumHealth(1000f);
enemy.Spawn(Vector2.right * .45f);
Physics.SyncTransforms();
CombatEvents.OnValidHit += OnValidHit;
yield return new WaitForFixedUpdate();
}
[UnityTearDown]
public IEnumerator TearDown()
{
CombatEvents.OnValidHit -= OnValidHit;
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
if (testRelicTuning != null) Object.Destroy(testRelicTuning);
if (testConstants != null) Object.Destroy(testConstants);
GameplayConstants.SetCurrentForTests(originalConstants);
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator ResonanceCoreReplaysImmediatelyWithoutExtraGaugeOrRelicRecursion()
{
Assert.That(world.Loadout.TryAcquireRelic(PassiveRelicId.ResonanceCore), Is.True);
world.Loadout.AddGauge(phoenix.NormalGaugeCost);
Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(phoenix.NormalGaugeCost));
float initialHealth = enemy.Actor.CurrentHealth;
float initialGauge = world.Loadout.Gauge;
Assert.That(world.Loadout.TryUse(false), Is.True);
Assert.That(ordinaryHits, Is.EqualTo(1));
Assert.That(replayHits, Is.EqualTo(1));
Assert.That(replayHitFrame, Is.EqualTo(ordinaryHitFrame),
"Echo damage must be applied synchronously in the original hit's frame.");
Assert.That(world.ArtifactExecutor.IsExecutingArtifact, Is.False);
float expectedDamage = DamageCalculator.FinalizeDamage(
world.Player.CalculateDamage(phoenix.NormalDamage, phoenix.DamageTag))
+ DamageCalculator.FinalizeDamage(world.Player.CalculateDamage(
phoenix.NormalDamage * testRelicTuning.ResonanceEchoDamageMultiplier, phoenix.DamageTag));
Assert.That(initialHealth - enemy.Actor.CurrentHealth,
Is.EqualTo(expectedDamage).Within(.02f));
Assert.That(ordinaryHits, Is.EqualTo(1));
Assert.That(replayHits, Is.EqualTo(1));
Assert.That(world.Loadout.Gauge,
Is.EqualTo(initialGauge - phoenix.NormalGaugeCost).Within(.001f));
yield return null;
}
[UnityTest]
public IEnumerator ResonantCrystalReplaysImmediatelyWhenDelayExpiresWithoutExtraGaugeOrAnotherPendingRepeat()
{
Assert.That(world.Loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True);
world.Loadout.AddGauge(phoenix.NormalGaugeCost);
Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(phoenix.NormalGaugeCost));
float initialHealth = enemy.Actor.CurrentHealth;
float initialGauge = world.Loadout.Gauge;
Assert.That(world.Loadout.TryUse(false), Is.True);
Assert.That(world.RelicRuntime.IsRepeatPending, Is.True);
Assert.That(ordinaryHits, Is.EqualTo(1));
Assert.That(replayHits, Is.Zero);
Assert.That(world.Loadout.Gauge,
Is.EqualTo(initialGauge - phoenix.NormalGaugeCost).Within(.001f));
float timeout = Time.realtimeSinceStartup + 2f;
while (world.RelicRuntime.IsRepeatPending && Time.realtimeSinceStartup < timeout)
{
yield return new WaitForFixedUpdate();
Assert.That(world.Player.IsAlive, Is.True, "The player must remain alive throughout the repeat delay.");
Assert.That(enemy.Actor.IsAlive, Is.True, "The target must survive the initial Phoenix hit and ignite ticks.");
Assert.That(Vector2.Distance(world.Player.Position, enemy.Actor.Position),
Is.LessThanOrEqualTo(phoenix.NormalRange), "The target must stay within the replay ray during its delay.");
}
float expectedDamage = DamageCalculator.FinalizeDamage(
world.Player.CalculateDamage(phoenix.NormalDamage, phoenix.DamageTag))
+ DamageCalculator.FinalizeDamage(world.Player.CalculateDamage(
phoenix.NormalDamage * testRelicTuning.ResonantCrystalDamageFraction, phoenix.DamageTag));
Assert.That(world.RelicRuntime.IsRepeatPending, Is.False);
Assert.That(replayHits, Is.EqualTo(1));
Assert.That(replayHitFrame, Is.GreaterThan(ordinaryHitFrame));
Assert.That(replayHitFrame, Is.EqualTo(Time.frameCount),
"Repeat damage must land in the frame the relic delay expires, not in a later cast step.");
Assert.That(world.ArtifactExecutor.IsExecutingArtifact, Is.False);
Assert.That(initialHealth - enemy.Actor.CurrentHealth,
Is.EqualTo(expectedDamage).Within(.02f));
Assert.That(ordinaryHits, Is.EqualTo(1));
Assert.That(replayHits, Is.EqualTo(1));
Assert.That(world.Loadout.Gauge,
Is.EqualTo(initialGauge - phoenix.NormalGaugeCost).Within(.001f));
yield return null;
}
private void OnValidHit(CombatHitResult hit)
{
if (hit.Target != enemy?.gameObject || !hit.IsArtifactHit) return;
if (hit.SourceId == Defense25DRelicRuntime.EchoSourceId)
{
replayHits++;
replayHitFrame = Time.frameCount;
}
else if (hit.SourceId == phoenix?.ArtifactId)
{
ordinaryHits++;
ordinaryHitFrame = Time.frameCount;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3e3510a879b4487f93b6235ebd55c7fc
@@ -0,0 +1,351 @@
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using NUnit.Framework;
using UnityEngine;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DRelicRuntimeTests
{
private PassiveRelicConstants tuning;
private Defense25DLoadout loadout;
private readonly List<ActiveArtifactDefinition> artifactDefinitions = new();
[SetUp]
public void SetUp()
{
tuning = ScriptableObject.CreateInstance<PassiveRelicConstants>();
loadout = new Defense25DLoadout(0f, 100f, 6, 6);
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(tuning);
foreach (ActiveArtifactDefinition definition in artifactDefinitions)
Object.DestroyImmediate(definition);
artifactDefinitions.Clear();
}
[Test]
public void TwinHornOnlyDoublesOrdinaryUncoloredBumpsWhenTheConfiguredRollSucceeds()
{
tuning.TwinHornDoubleDamageChance = 1f;
loadout.TryAcquireRelic(PassiveRelicId.TwinHornTalisman);
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects());
Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, true, false), Is.EqualTo(20f));
Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, false, false), Is.EqualTo(10f));
Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, true, true), Is.EqualTo(10f));
}
[Test]
public void WaveNailAppliesSourceDamageToNearbyEnemiesAndStartsCooldownOnlyOnAppliedHit()
{
tuning.WaveNailChance = 1f;
tuning.WaveNailDamageFraction = .6f;
tuning.WaveNailCooldownSeconds = 1.5f;
loadout.TryAcquireRelic(PassiveRelicId.WaveNail);
int applied = 0;
int attempts = 0;
bool allowDamage = false;
float observedDamage = 0f;
EnemyDamageSource observedSource = EnemyDamageSource.None;
Defense25DRelicTarget nearby = Target(2, new Vector3(1f, 0f, 0f));
Defense25DRelicEffects effects = new()
{
QueryEnemies = (_, _) => new[] { nearby },
DamageEnemy = (_, damage, _, source) =>
{
attempts++;
observedDamage = damage;
observedSource = source;
if (!allowDamage) return false;
applied++;
return true;
},
};
Defense25DRelicRuntime runtime = NewRuntime(effects);
runtime.BodyDiameterProvider = () => 1f;
runtime.NotifyHit(new Defense25DRelicHit(Target(1, Vector3.zero), Vector3.zero,
Vector3.right, 10f, 10f, true), 100f);
allowDamage = true;
runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero,
Vector3.right, 10f, 10f, true), 100f);
runtime.Advance(1f, 100f);
runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero,
Vector3.right, 10f, 10f, true), 100f);
Assert.That(applied, Is.EqualTo(1));
Assert.That(attempts, Is.EqualTo(2));
Assert.That(observedDamage, Is.EqualTo(6f));
Assert.That(observedSource, Is.EqualTo(EnemyDamageSource.PassiveRelicAttack));
}
[Test]
public void CounterBellSlowsEveryLivingEnemyInItsQueryInsteadOfStoppingAtFirstSuccess()
{
tuning.CounterBellChance = 1f;
loadout.TryAcquireRelic(PassiveRelicId.CounterBell);
List<int> slowed = new();
Defense25DRelicTarget[] targets =
{
Target(1, Vector3.zero),
Target(2, Vector3.right),
new(3, Vector3.forward, 10f, 10f, EnemyDamageSource.None, true),
};
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
QueryEnemies = (_, _) => targets,
ApplySlow = (target, _, _) => { slowed.Add(target.Identity); return true; },
});
runtime.BodyDiameterProvider = () => 1f;
runtime.NotifyPlayerDamageTaken(1f, 99f, 100f, Vector3.zero);
Assert.That(slowed, Is.EquivalentTo(new[] { 1, 2 }));
}
[Test]
public void LethalValidHitStillRunsWaveNailBloodFangAndResonanceCore()
{
tuning.WaveNailChance = 1f;
tuning.ResonanceEchoChance = 1f;
tuning.BloodFangChance = 1f;
loadout.TryAcquireRelic(PassiveRelicId.WaveNail);
loadout.TryAcquireRelic(PassiveRelicId.BloodFang);
loadout.TryAcquireRelic(PassiveRelicId.ResonanceCore);
ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Pulse);
Assert.That(loadout.TryAddArtifact(artifact), Is.True);
int damageCalls = 0, healCalls = 0, echoCalls = 0;
Defense25DRelicTarget nearby = Target(11, Vector3.right);
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
QueryEnemies = (_, _) => new[] { nearby },
DamageEnemy = (_, _, _, _) => { damageCalls++; return true; },
HealPlayer = _ => { healCalls++; return true; },
EmitEcho = (use, _, _, _) =>
{
Assert.That(use.Definition, Is.SameAs(artifact));
echoCalls++;
return true;
},
});
runtime.BodyDiameterProvider = () => 1f;
runtime.NotifyHit(new Defense25DRelicHit(
Target(10, Vector3.zero, isDead: true), Vector3.zero, Vector3.right,
10f, 10f, true), 50f, 100f);
Assert.That(damageCalls, Is.EqualTo(1));
Assert.That(healCalls, Is.EqualTo(1));
Assert.That(echoCalls, Is.EqualTo(1));
}
[Test]
public void WaveNailCanDamageTheOriginalHitTargetWhenItSurvivesTheBump()
{
tuning.WaveNailChance = 1f;
loadout.TryAcquireRelic(PassiveRelicId.WaveNail);
List<int> damaged = new();
Defense25DRelicTarget struck = Target(0, Vector3.zero);
Defense25DRelicTarget neighbor = Target(1, Vector3.right);
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
QueryEnemies = (_, _) => new[] { struck, neighbor },
DamageEnemy = (target, _, _, _) => { damaged.Add(target.Identity); return true; },
});
runtime.BodyDiameterProvider = () => 1f;
runtime.NotifyHit(new Defense25DRelicHit(struck, Vector3.zero, Vector3.right,
10f, 10f, true), 100f);
Assert.That(damaged, Is.EquivalentTo(new[] { 0, 1 }));
}
[Test]
public void WaveNailIncludesAnEnemyWhoseBodyOverlapsTheAreaAtItsOuterEdge()
{
tuning.WaveNailChance = 1f;
loadout.TryAcquireRelic(PassiveRelicId.WaveNail);
List<int> damaged = new();
Defense25DRelicTarget boundaryTarget = Target(12, new Vector3(3.2f, 0f, 0f));
const float targetBodyRadius = .3f;
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
QueryEnemies = (center, areaRadius) =>
Vector3.Distance(new Vector3(center.x, 0f, center.z),
new Vector3(boundaryTarget.Position.x, 0f, boundaryTarget.Position.z))
<= areaRadius + targetBodyRadius
? new[] { boundaryTarget }
: System.Array.Empty<Defense25DRelicTarget>(),
DamageEnemy = (target, _, _, _) => { damaged.Add(target.Identity); return true; },
});
runtime.BodyDiameterProvider = () => 1f;
runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero,
Vector3.right, 10f, 10f, true), 100f);
Assert.That(damaged, Is.EqualTo(new[] { boundaryTarget.Identity }));
}
[Test]
public void RiftWedgeAppliesThunderCrashVulnerabilityOnTheConfiguredOrdinaryHitCount()
{
tuning.RiftWedgeRequiredHits = 3;
loadout.TryAcquireRelic(PassiveRelicId.RiftWedge);
ActiveArtifactDefinition thunder = Artifact(ActiveArtifactEffect.ThunderCrash,
normalVulnerabilityDuration: 2.5f, normalVulnerabilityIncrease: .3f);
loadout.ConfigureCatalog(new[] { thunder });
int applied = 0;
Defense25DRelicEffects effects = new()
{
ApplyVulnerability = (_, duration, increase) =>
{
Assert.That(duration, Is.EqualTo(2.5f));
Assert.That(increase, Is.EqualTo(.3f));
applied++;
return true;
},
};
Defense25DRelicRuntime runtime = NewRuntime(effects);
Defense25DRelicTarget target = Target(7, Vector3.zero);
for (int i = 0; i < 2; i++)
runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right,
1f, 0f, true), 100f);
Assert.That(applied, Is.Zero);
runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right,
1f, 0f, true), 100f);
Assert.That(applied, Is.EqualTo(1));
}
[Test]
public void BondLedgerPaysOnlyForTowerKillsAfterRecentBumpAndDeduplicatesDeaths()
{
tuning.BondLedgerChance = 1f;
tuning.BondLedgerCurrency = 2;
tuning.BondLedgerBumpWindowSeconds = 3f;
loadout.TryAcquireRelic(PassiveRelicId.BondLedger);
int currency = 0;
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
AwardCurrency = amount => { currency += amount; return true; },
});
Defense25DRelicTarget target = Target(0, Vector3.zero);
runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right,
1f, 0f, true), 100f);
runtime.Advance(1f, 100f);
Defense25DRelicTarget towerKill = Target(0, Vector3.zero, EnemyDamageSource.Tower);
runtime.NotifyEnemyDeath(towerKill, 100f);
runtime.NotifyEnemyDeath(towerKill, 100f);
Assert.That(currency, Is.EqualTo(2));
}
[Test]
public void RunResetClearsRuntimeTimersAndTemporaryModifiersThroughOwnerCallbacks()
{
tuning.HunterFeatherChance = 1f;
loadout.TryAcquireRelic(PassiveRelicId.HunterFeather);
int added = 0, removed = 0;
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
SetTimedPlayerModifier = (_, _) => added++,
RemovePlayerModifier = _ => removed++,
});
runtime.NotifyEnemyDeath(Target(8, Vector3.zero, EnemyDamageSource.Player), 100f);
Assert.That(runtime.HunterExpiresAt, Is.GreaterThan(0f));
runtime.ResetRun(4f);
Assert.That(runtime.IsActive, Is.False);
Assert.That(runtime.HunterExpiresAt, Is.Zero);
Assert.That(added, Is.EqualTo(1));
Assert.That(removed, Is.GreaterThanOrEqualTo(1));
}
[Test]
public void ResonantCrystalRepeatExpiresIfItsDelayCompletesWhilePlayerIsDead()
{
tuning.ResonantCrystalChance = 1f;
tuning.ResonantCrystalDelaySeconds = 1f;
ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Cyclone);
Assert.That(loadout.TryAddArtifact(artifact), Is.True);
Assert.That(loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True);
int repeats = 0;
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
EmitRepeat = (_, _, _, _) => { repeats++; return true; },
});
runtime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false),
Vector3.zero, Vector3.right, 100f);
Assert.That(runtime.IsRepeatPending, Is.True);
runtime.Advance(1f, 0f);
runtime.Advance(2f, 100f);
Assert.That(runtime.IsRepeatPending, Is.False);
Assert.That(repeats, Is.Zero);
}
[Test]
public void ResonantCrystalRepeatStillFiresAfterDeathIfDelayHasNotEndedYet()
{
tuning.ResonantCrystalChance = 1f;
tuning.ResonantCrystalDelaySeconds = 1f;
ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Cyclone);
Assert.That(loadout.TryAddArtifact(artifact), Is.True);
Assert.That(loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True);
int repeats = 0;
Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects
{
EmitRepeat = (_, _, _, _) => { repeats++; return true; },
});
runtime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false),
Vector3.zero, Vector3.right, 100f);
runtime.Advance(.5f, 0f);
Assert.That(runtime.IsRepeatPending, Is.True);
runtime.Advance(1f, 100f);
Assert.That(runtime.IsRepeatPending, Is.False);
Assert.That(repeats, Is.EqualTo(1));
}
private Defense25DRelicRuntime NewRuntime(Defense25DRelicEffects effects)
{
Defense25DRelicRuntime runtime = new(loadout, tuning, effects, () => 0f);
runtime.BeginRun();
return runtime;
}
private ActiveArtifactDefinition Artifact(
ActiveArtifactEffect effect, float normalVulnerabilityDuration = 0f,
float normalVulnerabilityIncrease = 0f)
{
ActiveArtifactDefinition definition = ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
definition.Configure(effect.ToString(), effect.ToString(), "*", effect,
DamageTag.Collision, Color.white, Color.white, 0f, 0f, 1f,
1f, 1f, 1f, 1f, 1f, 1f,
normalBumpVulnerabilityIncrease: normalVulnerabilityIncrease,
normalBumpVulnerabilityDuration: normalVulnerabilityDuration);
artifactDefinitions.Add(definition);
return definition;
}
private static Defense25DRelicTarget Target(int id, Vector3 position,
EnemyDamageSource source = EnemyDamageSource.None, bool isDead = false) =>
new(id, position, 100f, 100f, source, isDead);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4d8d21d279da421a87e8af5b91db0012
@@ -0,0 +1,329 @@
using System.Collections;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Core;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DSavedSceneViewPlayModeTests
{
private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
private Defense25DComparison world;
[UnityTest]
public IEnumerator SavedSceneShowsAllBuildPadsRoutesAndPlayerWithNormalWallOcclusion()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
Assert.That(world, Is.Not.Null);
world.SetPaused(true);
Camera camera = world.View.Camera;
Assert.That(camera.transform.position.y, Is.GreaterThan(world.Settings.GroundY),
"The defense presentation camera must remain above the ground plane.");
Ray centerRay = camera.ViewportPointToRay(new Vector3(.5f, .5f));
Plane groundPlane = new(Vector3.up, new Vector3(0f, world.Settings.GroundY, 0f));
Assert.That(groundPlane.Raycast(centerRay, out float focusDistance), Is.True,
"The camera focus ray must intersect the defense ground plane.");
Assert.That((centerRay.GetPoint(focusDistance).y - world.Settings.GroundY),
Is.EqualTo(0f).Within(.001f));
camera.Render();
Color32[] pixels = ReadPixels(world.View.Texture);
for (int pad = 0; pad < 6; pad++)
{
Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad);
Vector3 padCenter = Defense25DActor.OnGround(ground, world.Settings.GroundY + .035f);
Vector3 screen = camera.WorldToScreenPoint(padCenter);
Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane), $"Pad {pad} must face the camera.");
Assert.That(screen.x, Is.InRange(8f, Defense25DView.Width - 8f), $"Pad {pad} must be on-screen.");
Assert.That(screen.y, Is.InRange(8f, Defense25DView.Height - 8f), $"Pad {pad} must be on-screen.");
GameObject padObject = GameObject.Find($"Tower build pad {pad + 1}");
Assert.That(padObject, Is.Not.Null);
Renderer padRenderer = padObject.GetComponent<Renderer>();
Assert.That(RendererContributesPixels(camera, pixels, padRenderer, screen), Is.GreaterThan(3),
$"Pad {pad} must contribute pixels through normal depth rendering.");
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
Vector2 displayPoint = viewport.position + new Vector2(
screen.x / Defense25DView.Width * viewport.width,
screen.y / Defense25DView.Height * viewport.height);
Assert.That(world.View.TryScreenToGround(displayPoint, world.Settings.GroundY, out Vector2 hit),
Is.True, $"Pad {pad} must have a usable screen-space interaction point.");
Assert.That(Vector2.Distance(hit, ground), Is.LessThan(.2f), $"Pad {pad} hit point must map to its ground position.");
}
foreach (Vector2 point in new[]
{
GameplayConstants.Current.Defense.GetPortalPosition(0),
GameplayConstants.Current.Defense.GetWaypointPosition(0, 0),
GameplayConstants.Current.Defense.GetPortalPosition(1),
GameplayConstants.Current.Defense.GetWaypointPosition(1, 0),
})
{
Vector3 routePoint = Defense25DActor.OnGround(point, world.Settings.GroundY + .06f);
Vector3 screen = camera.WorldToScreenPoint(routePoint);
Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane));
Assert.That(screen.x, Is.InRange(0f, Defense25DView.Width));
Assert.That(screen.y, Is.InRange(0f, Defense25DView.Height));
Assert.That(CountMaterialPixels(pixels, screen, world.Settings.PathMaterial.color), Is.GreaterThan(0),
$"Route marker at {point} must be pixel-visible.");
}
GameObject baseMarker = GameObject.Find("Shared base marker");
Assert.That(baseMarker, Is.Not.Null);
Renderer baseRenderer = baseMarker.GetComponent<Renderer>();
Vector3 baseScreen = camera.WorldToScreenPoint(baseRenderer.bounds.center);
Assert.That(RendererContributesPixels(camera, pixels, baseRenderer, baseScreen), Is.GreaterThan(0),
"The saved base marker itself must contribute visible pixels using its BaseMaterial.");
AssertActorVisible(pixels, world.Player.transform.position);
GameObject wall = GameObject.Find("South boundary");
Assert.That(wall, Is.Not.Null);
Collider wallCollider = wall.GetComponent<Collider>();
Renderer wallRenderer = wall.GetComponent<Renderer>();
Assert.That(wallCollider != null && wallCollider.enabled, Is.True, "The saved wall collider must remain enabled.");
Assert.That(wallRenderer != null && wallRenderer.enabled, Is.True);
Assert.That(wallCollider.bounds.size.y, Is.EqualTo(1.2f).Within(.02f),
"The boundary collision height must remain unchanged.");
Assert.That(wallRenderer.bounds.size.y, Is.LessThan(wallCollider.bounds.size.y),
"Only the rendered south boundary is lowered to expose the build pads.");
Bounds renderedWall = wallRenderer.bounds;
Vector3 wallSurfacePoint = new(renderedWall.center.x,
Mathf.Lerp(renderedWall.min.y, renderedWall.max.y, .75f), renderedWall.max.z);
Vector3 wallSurfaceScreen = camera.WorldToScreenPoint(wallSurfacePoint);
Ray ray = camera.ViewportPointToRay(new Vector3(wallSurfaceScreen.x / Defense25DView.Width,
wallSurfaceScreen.y / Defense25DView.Height));
Plane depthGroundPlane = new(Vector3.up, new Vector3(0f, world.Settings.GroundY + .25f, 0f));
Assert.That(depthGroundPlane.Raycast(ray, out float groundDistance), Is.True);
Vector3 behindWall = ray.GetPoint(groundDistance);
Vector3 behindScreen = camera.WorldToScreenPoint(behindWall);
string probeGeometry = $"probeWorld={behindWall}, probeScreen={behindScreen}, "
+ $"wallBounds={renderedWall}, cameraDepth=[{camera.nearClipPlane}, {camera.farClipPlane}]";
Assert.That(behindScreen.z, Is.InRange(camera.nearClipPlane, camera.farClipPlane),
$"The ground probe must be inside camera depth range: {probeGeometry}");
Assert.That(behindScreen.x, Is.InRange(0f, Defense25DView.Width),
$"The ground probe must project inside the render target: {probeGeometry}");
Assert.That(behindScreen.y, Is.InRange(0f, Defense25DView.Height),
$"The ground probe must project inside the render target: {probeGeometry}");
Assert.That(behindWall.z, Is.GreaterThan(renderedWall.max.z),
$"The depth probe must be on the ground behind the rendered wall bounds: {probeGeometry}");
Assert.That(Physics.Raycast(ray, out RaycastHit wallHit, Vector3.Distance(camera.transform.position, behindWall),
~0, QueryTriggerInteraction.Ignore) && wallHit.collider == wallCollider, Is.True,
"The wall must intersect the sightline from its visible mesh to ground geometry behind it.");
GameObject depthProbe = GameObject.CreatePrimitive(PrimitiveType.Cube);
depthProbe.transform.position = behindWall;
depthProbe.transform.localScale = new Vector3(.12f, .12f, .04f);
Renderer probeRenderer = depthProbe.GetComponent<Renderer>();
probeRenderer.sharedMaterial = world.Settings.PathMaterial;
Object.DestroyImmediate(depthProbe.GetComponent<Collider>());
camera.Render();
Color32[] wallWithProbe = ReadPixels(world.View.Texture);
probeRenderer.enabled = false;
camera.Render();
Color32[] wallWithoutProbe = ReadPixels(world.View.Texture);
probeRenderer.enabled = true;
Assert.That(DifferentPixelsNear(wallWithProbe, wallWithoutProbe, behindScreen, 18), Is.Zero,
"Normal wall depth must hide a ground-level probe behind the visible wall mesh.");
wallRenderer.enabled = false;
camera.Render();
Color32[] exposedWithProbe = ReadPixels(world.View.Texture);
probeRenderer.enabled = false;
camera.Render();
Color32[] exposedWithoutProbe = ReadPixels(world.View.Texture);
probeRenderer.enabled = true;
wallRenderer.enabled = true;
int exposedProbePixels = DifferentPixelsNear(exposedWithProbe, exposedWithoutProbe, behindScreen, 18);
Assert.That(exposedProbePixels, Is.GreaterThan(4),
$"Hiding the wall must reveal actual ground-level probe pixels through ordinary depth occlusion; "
+ $"changedPixels={exposedProbePixels}, probeBounds={probeRenderer.bounds}, {probeGeometry}");
Object.DestroyImmediate(depthProbe);
}
[UnityTest]
public IEnumerator SavedSceneSummonSlotsClearTheSouthWallAndCanAttack()
{
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
Assert.That(world, Is.Not.Null);
world.ReadInput = false;
world.AutoSpawn = false;
var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(choose, Is.Not.Null);
for (int i = 0; world.Paused && i < 3; i++)
Assert.That(choose.Invoke(world, new object[] { 0 }), Is.EqualTo(true));
Assert.That(world.Paused, Is.False);
DefenseConstants tuning = GameplayConstants.Current.Defense;
Vector2 pad = tuning.GetBuildPadPosition(1);
world.TowerManager.AddCurrency(1000);
world.Player.ResetAt(pad);
Physics.SyncTransforms();
Assert.That(world.TowerManager.TryBuildTowerAtPad(1, DefenseTowerKind.Summon), Is.True);
Defense25DTower tower = world.TowerManager.GetTowerAtPad(1);
BoxCollider southWall = GameObject.Find("South boundary").GetComponent<BoxCollider>();
BoxCollider northWall = GameObject.Find("North boundary").GetComponent<BoxCollider>();
BoxCollider eastWall = GameObject.Find("East boundary").GetComponent<BoxCollider>();
BoxCollider westWall = GameObject.Find("West boundary").GetComponent<BoxCollider>();
Bounds groundBounds = GameObject.Find("XZ ground").GetComponent<Renderer>().bounds;
Assert.That(groundBounds.min.z, Is.LessThanOrEqualTo(southWall.bounds.min.z + .001f),
"The runtime ground must span through the moved south wall thickness.");
Assert.That(groundBounds.max.z, Is.GreaterThanOrEqualTo(northWall.bounds.max.z - .001f),
"The runtime ground must span through the moved north wall thickness.");
Assert.That(eastWall.bounds.min.z, Is.LessThanOrEqualTo(southWall.bounds.min.z + .001f));
Assert.That(eastWall.bounds.max.z, Is.GreaterThanOrEqualTo(northWall.bounds.max.z - .001f));
Assert.That(westWall.bounds.min.z, Is.LessThanOrEqualTo(southWall.bounds.min.z + .001f));
Assert.That(westWall.bounds.max.z, Is.GreaterThanOrEqualTo(northWall.bounds.max.z - .001f));
Assert.That(southWall.bounds.Intersects(eastWall.bounds), Is.True,
"The expanded south and east wall colliders must remain connected.");
Assert.That(southWall.bounds.Intersects(westWall.bounds), Is.True);
Assert.That(northWall.bounds.Intersects(eastWall.bounds), Is.True);
Assert.That(northWall.bounds.Intersects(westWall.bounds), Is.True);
float deepestT4Slot = pad.y - 1.35f;
Assert.That(southWall.bounds.max.z, Is.LessThan(deepestT4Slot
- tuning.SummonUnitCollisionRadius * 1.55f),
"The saved comparison boundary must leave room for the largest T4 pack slot and variant footprint.");
Vector3 deepestSlotScreen = world.View.Camera.WorldToScreenPoint(
Defense25DActor.OnGround(new Vector2(pad.x, deepestT4Slot), world.Settings.GroundY)
+ Vector3.up * .45f);
Assert.That(deepestSlotScreen.z, Is.GreaterThan(world.View.Camera.nearClipPlane));
Assert.That(deepestSlotScreen.x, Is.InRange(0f, Defense25DView.Width));
Assert.That(deepestSlotScreen.y, Is.InRange(0f, Defense25DView.Height),
"The expanded slot footprint must remain inside the saved comparison camera view.");
foreach (Defense25DSummon summon in world.Summons.Where(item => item.Owner == tower))
{
Vector2 originalSlot = pad + new Vector2((summon.SlotIndex % 3 - 1) * .8f,
-.55f - .8f * (summon.SlotIndex / 3));
Assert.That(summon.Actor.Position, Is.EqualTo(originalSlot),
"Runtime map clearance must not rewrite the original summon slot policy.");
Assert.That(Physics.ComputePenetration(summon.Actor.BodyCollider, summon.transform.position,
summon.transform.rotation, southWall, southWall.transform.position, southWall.transform.rotation,
out _, out _), Is.False, $"Summon slot {summon.SlotIndex} must not spawn inside the saved wall.");
}
Defense25DSummon[] pack = world.Summons.Where(item => item.Owner == tower).ToArray();
Assert.That(pack.Length, Is.GreaterThan(0));
Defense25DEnemy target = world.SpawnNextEnemy();
Assert.That(target.Lane, Is.EqualTo(0));
target.Actor.SetEnemyMaximumHealth(1000f);
target.Actor.ResetAt(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon));
target.ApplyStun(20f);
world.Player.ResetAt(Vector2.zero);
Physics.SyncTransforms();
float attackDeadline = Time.realtimeSinceStartup + 8f;
while (pack.All(item => item.AttackEvents == 0)
&& Time.realtimeSinceStartup < attackDeadline)
yield return new WaitForFixedUpdate();
string packState = string.Join("; ", pack.Select(item =>
{
Collider blocker = item.Actor.LastMovementBlocker;
string blockerState = blocker == null ? "none"
: $"{blocker.name}[layer={LayerMask.LayerToName(blocker.gameObject.layer)} "
+ $"bounds={blocker.bounds}]";
return $"slot={item.SlotIndex} position={item.Actor.Position} "
+ $"blocked={item.Actor.MovementBlocked} blocker={blockerState} "
+ $"gap={item.Actor.GapTo(target.Actor):0.000} attacking={item.IsAttacking} "
+ $"hits={item.AttackEvents} lastMove={item.Actor.LastMove}";
}));
Assert.That(pack.Sum(item => item.AttackEvents), Is.GreaterThan(0),
$"At least one summon in the saved-scene pack must attack with the south boundary present. "
+ $"commandTarget={tower.CommandTarget}, targetAvailable={target.IsAvailable}, "
+ $"gameTime={world.GameTime:0.00}, playerAlive={world.Player.IsAlive}, "
+ $"targetPosition={target.Actor.Position}; pack: {packState}");
Assert.That(target.Actor.CurrentHealth, Is.LessThan(target.Actor.MaxHealth));
}
private static void AssertActorVisible(Color32[] pixels, Vector3 worldPosition)
{
Defense25DView view = Object.FindAnyObjectByType<Defense25DView>();
Vector3 screen = view.Camera.WorldToScreenPoint(worldPosition + Vector3.up * .45f);
Assert.That(screen.z, Is.GreaterThan(view.Camera.nearClipPlane));
int different = 0;
for (int y = -12; y <= 12; y++)
for (int x = -10; x <= 10; x++)
{
int px = Mathf.RoundToInt(screen.x) + x, py = Mathf.RoundToInt(screen.y) + y;
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
Color32 pixel = pixels[py * Defense25DView.Width + px];
if (pixel.r > 90 || pixel.g > 90 || pixel.b > 90) different++;
}
Assert.That(different, Is.GreaterThan(3), "The player sprite must contribute visible pixels.");
}
private static int CountMaterialPixels(Color32[] pixels, Vector3 screen, Color expected)
{
int count = 0;
int cx = Mathf.RoundToInt(screen.x), cy = Mathf.RoundToInt(screen.y);
for (int y = -10; y <= 10; y++)
for (int x = -10; x <= 10; x++)
{
int px = cx + x, py = cy + y;
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
Color32 pixel = pixels[py * Defense25DView.Width + px];
if (Mathf.Abs(pixel.r - expected.r * 255f) < 45f
&& Mathf.Abs(pixel.g - expected.g * 255f) < 45f
&& Mathf.Abs(pixel.b - expected.b * 255f) < 45f) count++;
}
return count;
}
private static int RendererContributesPixels(Camera camera, Color32[] visible,
Renderer renderer, Vector3 screen)
{
bool wasEnabled = renderer.enabled;
renderer.enabled = false;
camera.Render();
Color32[] hidden = ReadPixels(camera.targetTexture as RenderTexture);
renderer.enabled = wasEnabled;
camera.Render();
return DifferentPixelsNear(visible, hidden, screen, 18);
}
private static int DifferentPixelsNear(Color32[] a, Color32[] b, Vector3 center, int radius)
{
int count = 0, cx = Mathf.RoundToInt(center.x), cy = Mathf.RoundToInt(center.y);
for (int y = -radius; y <= radius; y++)
for (int x = -radius; x <= radius; x++)
{
int px = cx + x, py = cy + y;
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
int index = py * Defense25DView.Width + px;
if (!a[index].Equals(b[index])) count++;
}
return count;
}
private static Color32[] ReadPixels(RenderTexture texture)
{
RenderTexture previous = RenderTexture.active;
RenderTexture.active = texture;
Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0);
image.Apply();
Color32[] pixels = image.GetPixels32();
RenderTexture.active = previous;
Object.DestroyImmediate(image);
return pixels;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d09d0d9e789c4b6686ed1292a3e0f86f
@@ -0,0 +1,92 @@
using System.Collections;
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DSkeletonCombatPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private AnimationClip attackClip;
private float previousTimeScale;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
Assert.That(settings, Is.Not.Null);
world = new GameObject("Skeleton combat under test").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
world.SetPaused(false);
Time.timeScale = 0f;
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator SkeletonSlashDamagesOnceWhenItsAuthoredActiveWindowBegins()
{
EnemyDefinition skeleton = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Skeleton.asset");
attackClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
"Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim");
Assert.That(skeleton, Is.Not.Null);
Assert.That(attackClip, Is.Not.Null);
Defense25DEnemy enemy = world.Enemies[0];
Assert.That(world.IsRunning, Is.True);
enemy.Spawn(Vector2.zero);
enemy.ConfigureSkeletonCombat(skeleton, 1.25f, attackClip);
Assert.That(world.Settings.EnemyRadius, Is.EqualTo(.275f).Within(.0001f));
Assert.That(world.Settings.EnemyColliderOffset, Is.EqualTo(new Vector2(0f, -.35f)));
world.Player.ResetAt(new Vector2(.5f, 0f));
Physics.SyncTransforms();
SetGameTime(0f);
Assert.That(enemy.StepSkeletonCombat(Time.fixedDeltaTime), Is.True);
Assert.That(world.IsRunning, Is.True);
Assert.That(enemy.IsAttacking, Is.True);
SetGameTime(.699f);
enemy.StepSkeletonCombat(Time.fixedDeltaTime);
Assert.That(world.Player.DamageEvents, Is.Zero,
"The warning phase must not deal damage.");
SetGameTime(.7f);
enemy.StepSkeletonCombat(Time.fixedDeltaTime);
Assert.That(world.Player.DamageEvents, Is.EqualTo(1));
Assert.That(world.Player.CurrentHealth, Is.EqualTo(world.Player.MaxHealth - 12f).Within(.001f));
SetGameTime(.72f);
enemy.StepSkeletonCombat(Time.fixedDeltaTime);
Assert.That(world.Player.DamageEvents, Is.EqualTo(1),
"A successful slash is latched for the rest of its active window.");
yield return null;
}
private void SetGameTime(float value)
{
typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.GameTime))
.GetSetMethod(true).Invoke(world, new object[] { value });
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 785220a7fcfa4c80bf7dc14b6beae34b
@@ -0,0 +1,147 @@
using System.Collections;
using System.Reflection;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DStatusVisualPlayModeTests
{
private GameObject enemyObject;
private GameObject actorObject;
private GameObject spriteObject;
private GameObject worldObject;
[UnityTearDown]
public IEnumerator TearDown()
{
if (worldObject != null) Object.Destroy(worldObject);
else
{
if (enemyObject != null) Object.Destroy(enemyObject);
if (actorObject != null) Object.Destroy(actorObject);
if (spriteObject != null) Object.Destroy(spriteObject);
}
yield return null;
}
[UnityTest]
public IEnumerator ActiveStatusesRenderExistingFramesAndClearWhenTheirTimersExpire()
{
const string ignitePath = "Artifacts/ThreeColor-v1/StatusEffects/Ignite-Warlock-v1";
Sprite[] importedFrames = Resources.LoadAll<Sprite>(ignitePath);
Texture2D igniteTexture = importedFrames.Length > 0
? importedFrames[0].texture : Resources.Load<Texture2D>(ignitePath);
Assert.That(igniteTexture, Is.Not.Null);
worldObject = new GameObject("Status visual world");
Defense25DComparison owner = worldObject.AddComponent<Defense25DComparison>();
SetBackingField(owner, "IsRunning", true);
SetBackingField(owner, "Paused", true);
spriteObject = new GameObject("Status visual source");
SpriteRenderer sourceRenderer = spriteObject.AddComponent<SpriteRenderer>();
actorObject = new GameObject("Status visual actor");
actorObject.transform.SetParent(worldObject.transform, false);
spriteObject.transform.SetParent(actorObject.transform, false);
spriteObject.transform.rotation = Quaternion.Euler(30f, 0f, 0f);
sourceRenderer.sortingOrder = 12;
sourceRenderer.flipX = true;
sourceRenderer.color = new Color(1f, 1f, 1f, .4f);
Defense25DActor actor = actorObject.AddComponent<Defense25DActor>();
SetBackingField(actor, "CurrentHealth", 10f);
Defense25DSprite actorVisual = actorObject.AddComponent<Defense25DSprite>();
SetPrivateField(actorVisual, "sprite", sourceRenderer);
actorVisual.enabled = false;
SetBackingField(actor, "Visual", actorVisual);
enemyObject = new GameObject("Status visual enemy");
enemyObject.transform.SetParent(worldObject.transform, false);
Defense25DEnemy enemy = enemyObject.AddComponent<Defense25DEnemy>();
SetBackingField(enemy, "Actor", actor);
SetPrivateField(enemy, "world", owner);
SetPrivateField(enemy, "igniteRemaining", .5f);
SetPrivateField(enemy, "shockRemaining", .5f);
SetPrivateField(enemy, "stunUntil", .5f);
SetPrivateField(enemy, "stunVisualRevision", 1);
Defense25DStatusVisual statusVisual = enemyObject.AddComponent<Defense25DStatusVisual>();
statusVisual.Bind(enemy);
statusVisual.SendMessage("LateUpdate");
Transform igniteVisual = spriteObject.transform.Find("Ignite Status Visual");
Transform shockVisual = spriteObject.transform.Find("Shock Status Visual");
Assert.That(igniteVisual, Is.Not.Null);
Assert.That(shockVisual, Is.Not.Null);
Assert.That(enemy.IsIgnited, Is.True);
Assert.That(enemy.IsShocked, Is.True);
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "FrameElapsed"),
Is.EqualTo(0f));
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "shock"), "FrameElapsed"),
Is.EqualTo(0f));
Assert.That(GetPrivateField(statusVisual, "stunElapsed"), Is.EqualTo(0f));
Transform stunMarker = actorObject.transform.Find("Stun Runes");
Assert.That(stunMarker, Is.Not.Null);
SpriteRenderer stunRenderer = stunMarker.GetComponent<SpriteRenderer>();
Assert.That(stunRenderer.sharedMaterial, Is.SameAs(sourceRenderer.sharedMaterial));
Assert.That(stunRenderer.color.a, Is.EqualTo(sourceRenderer.color.a).Within(.001f));
Assert.That(stunMarker.localPosition.z, Is.GreaterThan(0f));
SpriteRenderer igniteRenderer = igniteVisual.GetComponent<SpriteRenderer>();
Assert.That(igniteRenderer.sprite, Is.Not.Null);
Assert.That(igniteRenderer.sprite.texture, Is.SameAs(igniteTexture));
Assert.That(igniteRenderer.sortingOrder, Is.EqualTo(sourceRenderer.sortingOrder + 1));
Assert.That(igniteRenderer.flipX, Is.EqualTo(sourceRenderer.flipX));
Assert.That(igniteRenderer.color.a, Is.EqualTo(sourceRenderer.color.a).Within(.001f));
Assert.That(igniteVisual.localPosition.y,
Is.EqualTo(Defense25DStatusVisual.StatusVisualAnchorOffsetY).Within(.001f));
SetPrivateField(enemy, "igniteRemaining", 0f);
SetPrivateField(enemy, "shockRemaining", 0f);
statusVisual.SendMessage("LateUpdate");
Assert.That(enemy.IsIgnited, Is.False);
Assert.That(enemy.IsShocked, Is.False);
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Null);
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "shock"), "Root"), Is.Null);
SetPrivateField(enemy, "igniteRemaining", .5f);
SetBackingField(owner, "Paused", false);
statusVisual.SendMessage("LateUpdate");
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Not.Null);
SetBackingField(owner, "IsRunning", false);
statusVisual.SendMessage("LateUpdate");
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Null);
SetBackingField(owner, "IsRunning", true);
statusVisual.SendMessage("LateUpdate");
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Not.Null);
owner.enabled = false;
statusVisual.SendMessage("LateUpdate");
Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Null);
yield return null;
}
private static void SetBackingField(object target, string propertyName, object value) =>
SetPrivateField(target, $"<{propertyName}>k__BackingField", value);
private static void SetPrivateField(object target, string fieldName, object value)
{
FieldInfo field = target.GetType().GetField(
fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
Assert.That(field, Is.Not.Null, $"Missing test field {fieldName}");
field.SetValue(target, value);
}
private static object GetPrivateField(object target, string fieldName)
{
FieldInfo field = target.GetType().GetField(
fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
Assert.That(field, Is.Not.Null, $"Missing test field {fieldName}");
return field.GetValue(target);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7751eb9767744bb198826d23a801b32d
@@ -0,0 +1,270 @@
using System.Collections;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace BumpCombat.Tests
{
public sealed class Defense25DSummonPackPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
[UnitySetUp]
public IEnumerator SetUp()
{
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
world = new GameObject("2.5D summon pack test").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
yield return null;
}
[Test]
public void UpgradeReplacesEverySlotAndResetsThePackTimer()
{
Defense25DTower tower = BuildSummonTower();
Defense25DSummon oldUnit = world.Summons[0];
oldUnit.Actor.TakeDamage(1000f, Vector2.zero);
Assert.That(tower.SummonRespawnRemaining,
Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitRespawnSeconds));
UpgradeToTierFour(tower, DefenseTowerBranch.Second);
int expected = GameplayConstants.Current.Defense.SummonUnitCount + 4;
Assert.That(tower.TargetSummonCount, Is.EqualTo(expected));
Assert.That(tower.SummonCount, Is.EqualTo(expected));
Assert.That(tower.SummonRespawnRemaining, Is.Zero);
Assert.That(world.Summons.Count, Is.EqualTo(expected));
CollectionAssert.DoesNotContain(world.Summons, oldUnit);
CollectionAssert.AreEquivalent(System.Linq.Enumerable.Range(0, expected),
System.Linq.Enumerable.Select(world.Summons, summon => summon.SlotIndex));
}
[UnityTest]
public IEnumerator BasicSlimeDealsNoInstantDamageAndRequiresAttackFrameContact()
{
Defense25DTower tower = BuildSummonTower();
Defense25DSummon summon = world.Summons[0];
foreach (Defense25DSummon other in world.Summons)
if (other != summon) other.gameObject.SetActive(false);
world.Player.ResetAt(new Vector2(4f, 4f));
Defense25DEnemy target = SpawnSummonVulnerableEnemy();
target.Actor.ResetAt(summon.Actor.Position + Vector2.right * .9f);
Physics.SyncTransforms();
tower.StepSummonPack(0f);
float health = target.Actor.CurrentHealth;
float elapsed = 0f;
while (elapsed < .124f)
{
yield return new WaitForFixedUpdate();
elapsed += Time.fixedDeltaTime;
}
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health),
"the body-slam hit is gated by the authored impact frame");
float deadline = Time.realtimeSinceStartup + 2f;
while (target.Actor.CurrentHealth >= health && Time.realtimeSinceStartup < deadline)
yield return new WaitForFixedUpdate();
Assert.That(target.Actor.CurrentHealth, Is.LessThan(health),
"the later attack frame must make physical contact before applying damage");
Assert.That(summon.AttackEvents, Is.EqualTo(1),
"the isolated summon must own the only successful damage event");
Assert.That(summon.LastDamageFrame, Is.GreaterThanOrEqualTo(
GameplayConstants.Current.Defense.SummonBodySlamImpactFrame),
"damage must occur on or after the authored impact frame");
Assert.That(summon.Actor.GapTo(target.Actor), Is.LessThanOrEqualTo(
GameplayConstants.Current.Defense.SummonBodySlamContactMargin),
"damage requires physical body contact");
yield return null;
}
[Test]
public void SummonStartsAttackUsingPositionReachedByThisStep()
{
Defense25DTower tower = BuildSummonTower();
Defense25DSummon summon = world.Summons[0];
foreach (Defense25DSummon other in world.Summons)
if (other != summon) other.gameObject.SetActive(false);
world.Player.ResetAt(new Vector2(4f, 4f));
Defense25DEnemy target = SpawnSummonVulnerableEnemy();
target.Actor.ResetAt(summon.Actor.Position + Vector2.right * 1.25f);
Physics.SyncTransforms();
tower.StepSummonPack(.2f);
summon.Step(.2f);
Assert.That(summon.IsAttacking, Is.True,
$"The original starts an attack when the post-move root position enters attack range. "
+ $"target={tower.CommandTarget}, running={world.IsRunning}, operational={world.TowersOperational}, "
+ $"available={target.IsAvailable}, summon={summon.Actor.Position}, "
+ $"targetPosition={target.Actor.Position}, blocked={summon.Actor.MovementBlocked}, "
+ $"lastMove={summon.Actor.LastMove}");
}
[UnityTest]
public IEnumerator SummonWithNoValidTargetReturnsToItsAnchor()
{
Defense25DTower tower = BuildSummonTower();
Defense25DSummon summon = world.Summons[0];
foreach (Defense25DSummon other in world.Summons)
if (other != summon) other.gameObject.SetActive(false);
world.Player.ResetAt(new Vector2(4f, 4f));
Vector2 anchor = summon.Actor.Position;
summon.Actor.ResetAt(anchor + Vector2.right * .5f);
Physics.SyncTransforms();
Vector2 before = summon.Actor.Position;
tower.StepSummonPack(.2f);
summon.Step(.2f);
Assert.That(tower.CommandTarget, Is.Null);
Assert.That(Vector2.Dot(summon.Actor.LastMove, anchor - before),
Is.GreaterThan(0f),
"The original moves toward its anchor while no command target is valid.");
yield return null;
}
[UnityTest]
public IEnumerator OnlyBasicSlimeReturnsToItsAnchorAfterAttack()
{
Defense25DTower tower = BuildSummonTower();
Defense25DSummon summon = world.Summons[0];
foreach (Defense25DSummon other in world.Summons)
if (other != summon) other.gameObject.SetActive(false);
world.Player.ResetAt(new Vector2(4f, 4f));
Defense25DEnemy target = SpawnSummonVulnerableEnemy();
target.Actor.ResetAt(summon.Actor.Position + Vector2.right * .7f);
Physics.SyncTransforms();
tower.StepSummonPack(.01f);
summon.Step(.01f);
Assert.That(summon.IsAttacking, Is.True,
$"Basic summon should start when its target is in range; target={tower.CommandTarget}, "
+ $"running={world.IsRunning}, operational={world.TowersOperational}, "
+ $"available={target.IsAvailable}, summon={summon.Actor.Position}, targetPosition={target.Actor.Position}");
float deadline = Time.realtimeSinceStartup + 2f;
while (summon.IsAttacking && Time.realtimeSinceStartup < deadline)
yield return new WaitForFixedUpdate();
Assert.That(summon.IsAttacking, Is.False);
Assert.That(summon.IsReturningToAnchor, Is.True,
"Basic Slime returns after its attack sequence.");
yield return null;
}
[UnityTest]
public IEnumerator TierFourSentinelDoesNotReturnAfterAttack()
{
Defense25DTower tower = BuildSummonTower();
UpgradeToTierFour(tower, DefenseTowerBranch.First);
Defense25DSummon summon = world.Summons[0];
foreach (Defense25DSummon other in world.Summons)
if (other != summon) other.gameObject.SetActive(false);
world.Player.ResetAt(new Vector2(4f, 4f));
Defense25DEnemy target = SpawnSummonVulnerableEnemy();
target.Actor.ResetAt(summon.Actor.Position + Vector2.right * .7f);
Physics.SyncTransforms();
tower.StepSummonPack(.01f);
summon.Step(.01f);
Assert.That(summon.IsAttacking, Is.True,
$"Sentinel should start its attack in range; target={tower.CommandTarget}, "
+ $"running={world.IsRunning}, operational={world.TowersOperational}, "
+ $"available={target.IsAvailable}, summon={summon.Actor.Position}, targetPosition={target.Actor.Position}");
float deadline = Time.realtimeSinceStartup + 2f;
while (summon.IsAttacking && Time.realtimeSinceStartup < deadline)
yield return new WaitForFixedUpdate();
Assert.That(summon.IsAttacking, Is.False);
Assert.That(summon.IsReturningToAnchor, Is.False,
"Original return-to-anchor behavior is limited to basic Slime attacks.");
yield return null;
}
[UnityTest]
public IEnumerator ScoutGroundSpikeWaitsForItsOriginalLaunchAndTravelTime()
{
Defense25DTower tower = BuildSummonTower();
UpgradeToTierFour(tower, DefenseTowerBranch.Second);
Defense25DSummon scout = world.Summons[0];
Defense25DEnemy target = SpawnSummonVulnerableEnemy();
target.Actor.ResetAt(tower.Position + Vector2.right);
Physics.SyncTransforms();
float health = target.Actor.CurrentHealth;
Assert.That(tower.TryAttackSummon(scout, target), Is.True);
tower.StepParityAttacks(.48f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "launch frame is not impact");
tower.StepParityAttacks(.159f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile remains in flight");
tower.StepParityAttacks(.001f);
Assert.That(target.Actor.CurrentHealth, Is.LessThan(health));
yield return null;
}
[UnityTest]
public IEnumerator SummonRespawnTimerPausesWhilePlayerIsDead()
{
Defense25DTower tower = BuildSummonTower();
Defense25DSummon summon = world.Summons[0];
summon.Actor.TakeDamage(1000f, Vector2.zero);
float respawnSeconds = GameplayConstants.Current.Defense.SummonUnitRespawnSeconds;
Assert.That(tower.SummonRespawnRemaining, Is.EqualTo(respawnSeconds));
Assert.That(world.Player.TakeDamage(world.Player.CurrentHealth + 1f, Vector2.left), Is.True);
Assert.That(world.Player.IsAlive, Is.False, "the timer pause requires an actually dead player");
Assert.That(world.TowersOperational, Is.False);
tower.StepSummonPack(1f);
Assert.That(tower.SummonRespawnRemaining, Is.EqualTo(respawnSeconds));
Assert.That(tower.SummonCount, Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount - 1));
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
tower.StepSummonPack(respawnSeconds - .01f);
Assert.That(tower.SummonCount, Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount - 1));
tower.StepSummonPack(.02f);
Assert.That(tower.SummonCount, Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount));
yield return null;
}
private Defense25DTower BuildSummonTower()
{
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
return world.TowerManager.GetTowerAtPad(0);
}
private void UpgradeToTierFour(Defense25DTower tower, DefenseTowerBranch branch)
{
world.TowerManager.AddCurrency(1000);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(tower.PadIndex, DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(tower.PadIndex, DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(tower.PadIndex, branch), Is.True);
}
private Defense25DEnemy SpawnSummonVulnerableEnemy()
{
for (int i = 0; i < 2; i++)
{
Defense25DEnemy candidate = world.SpawnNextEnemy();
if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, candidate)) return candidate;
}
Assert.Fail("Both portal lanes unexpectedly reject summon-tower damage.");
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4987f4d643f34729ab7405159683701e
@@ -0,0 +1,482 @@
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class Defense25DTowerAttackParityPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private EnemyDefinition highHealthEnemy;
[SetUp]
public void SetUp()
{
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
EnemyDefinition sourceEnemy = settings.Enemy;
highHealthEnemy = Object.Instantiate(sourceEnemy);
highHealthEnemy.name = sourceEnemy.name + " Flame Ramp Test";
highHealthEnemy.Configure(sourceEnemy.Kind, sourceEnemy.AttackShape, 10000f,
sourceEnemy.MoveSpeed, sourceEnemy.AttackDamage, sourceEnemy.WarningDuration,
sourceEnemy.ActiveDuration, sourceEnemy.RecoveryDuration, sourceEnemy.AttackRange,
sourceEnemy.PreferredRange, sourceEnemy.AttackLength, sourceEnemy.AttackWidth,
sourceEnemy.AttackRadius, sourceEnemy.ExperienceValue, sourceEnemy.AttackAnimationLeadTime);
highHealthEnemy.ConfigureRole(sourceEnemy.Role);
highHealthEnemy.ConfigureAttackPatterns(sourceEnemy.AttackPatterns);
settings.Enemy = highHealthEnemy;
world = new GameObject("2.5D tower attack parity fixture").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
}
[TearDown]
public void TearDown()
{
if (world != null) Object.DestroyImmediate(world.gameObject);
if (highHealthEnemy != null) Object.DestroyImmediate(highHealthEnemy);
if (settings != null) Object.DestroyImmediate(settings);
}
[Test]
public void MeteorFinalBranchShowsFlightAndDealsDamageOnlyAtAuthoredArrival()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Direct, DefenseTowerBranch.First);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Direct, tower.Position + Vector2.right);
float before = target.Actor.CurrentHealth;
tower.StepParityAttacks(0f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before), "Meteor must not damage on launch.");
Assert.That(FindNamed("Tower Projectile").Length, Is.EqualTo(1));
tower.StepParityAttacks(.319f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before), "The authored flight is 0.32 seconds.");
tower.StepParityAttacks(.001f);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0), "The arrival callback must produce a hit.");
Assert.That(FindNamed("Tower Attack Effect").Length, Is.GreaterThan(0), "Meteor impact art is emitted on arrival.");
}
[Test]
public void FlamethrowerFinalBranchKeepsBeamAndUsesCooldownAndContinuousRamp()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Direct, DefenseTowerBranch.Second);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Direct,
tower.Position + Vector2.right, immobilize: true);
float previous = target.Actor.CurrentHealth;
StepWorld(1);
Assert.That(target.Actor.CurrentHealth, Is.LessThan(previous));
previous = target.Actor.CurrentHealth;
Assert.That(FindNamed("Fire T4 Sustained Beam").Length, Is.EqualTo(1));
StepWorld(9);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(previous),
"The beam must respect its authored pulse cooldown instead of damaging each frame.");
StepWorld(2);
Assert.That(target.Actor.CurrentHealth, Is.LessThan(previous), "A cooldown pulse lands after its interval.");
float earlyPulse = previous - target.Actor.CurrentHealth;
SpriteRenderer beam = FindNamed("Fire T4 Sustained Beam").Single()
.GetComponentInChildren<SpriteRenderer>();
Assert.That(beam.sprite, Is.EqualTo(DefenseTowerArt.Load(
"Effects/Fire/Eyeball Monster_Beam-Clean")[1]),
"The sustained beam advances to its authored ignition frame after 0.1 seconds.");
StepWorld(Mathf.CeilToInt(GameplayConstants.Current.Defense.FireT4BeamSecondTierSeconds
/ Time.fixedDeltaTime));
float beforeLatePulse = target.Actor.CurrentHealth;
StepWorld(11);
Assert.That(target.Actor.CurrentHealth, Is.LessThan(beforeLatePulse));
float rampedPulse = beforeLatePulse - target.Actor.CurrentHealth;
Assert.That(rampedPulse, Is.GreaterThan(earlyPulse * 1.25f),
"Continuous target time must advance the flame damage stage while retaining the same target.");
}
[Test]
public void FlamethrowerRetainsTowerRampAcrossTargetSwapAndResetsAfterOneSecondWithoutHits()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Direct, DefenseTowerBranch.Second);
Defense25DEnemy first = SpawnTarget(DefenseTowerKind.Direct,
tower.Position + Vector2.right, immobilize: true);
StepWorld(1);
StepWorld(Mathf.CeilToInt(GameplayConstants.Current.Defense.FireT4BeamSecondTierSeconds
/ Time.fixedDeltaTime) + 2);
first.Actor.ResetAt(tower.Position + Vector2.right * 8f);
Defense25DEnemy replacement = SpawnTarget(DefenseTowerKind.Direct,
tower.Position + Vector2.right * .9f, immobilize: true);
float secondTierDamage = NextDamage(replacement);
float baseDamage = GameplayConstants.Current.Defense.DirectTowerDamage
* (1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier * 3f)
* GameplayConstants.Current.Defense.FireT4BeamBaseDamageMultiplier
* tower.SightEfficiency;
Assert.That(secondTierDamage, Is.EqualTo(DamageCalculator.FinalizeDamage(baseDamage
* GameplayConstants.Current.Defense.FireT4BeamSecondTierMultiplier)),
"The streak clock belongs to the tower and survives changing its target.");
float remainingToMaximum = GameplayConstants.Current.Defense.FireT4BeamMaximumTierSeconds
- world.GameTime;
StepWorld(Mathf.CeilToInt(remainingToMaximum / Time.fixedDeltaTime) + 2);
float maximumTierDamage = NextDamage(replacement);
Assert.That(maximumTierDamage, Is.EqualTo(DamageCalculator.FinalizeDamage(baseDamage
* GameplayConstants.Current.Defense.FireT4BeamMaximumMultiplier)),
"The sustained ramp reaches the original maximum tier after two seconds.");
replacement.Actor.ResetAt(tower.Position + Vector2.right * 8f);
StepWorld(Mathf.CeilToInt(GameplayConstants.Current.Defense.FireT4BeamResetAfterSeconds
/ Time.fixedDeltaTime) + 1);
Defense25DEnemy afterReset = SpawnTarget(DefenseTowerKind.Direct,
tower.Position + Vector2.right * .9f, immobilize: true);
float resetDamage = NextDamage(afterReset);
Assert.That(resetDamage, Is.EqualTo(DamageCalculator.FinalizeDamage(baseDamage)),
"One second without a successful hit resets the tower's flame stage.");
}
[Test]
public void ArcFinalBranchHitsAtMostThreeTargetsAndEmitsItsAttackVisual()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Lightning, DefenseTowerBranch.First);
Defense25DEnemy[] targets = SpawnTargets(DefenseTowerKind.Lightning, tower.Position, 4);
tower.StepParityAttacks(0f);
Assert.That(targets.Count(enemy => enemy.Actor.DamageEvents > 0), Is.EqualTo(3),
"The Arc branch is capped at three actual hits.");
Assert.That(world.ActiveTowerDamagePopupCount, Is.GreaterThan(0),
"Actual tower health loss is surfaced through the comparison HUD damage popup.");
GameObject visual = FindNamed("Lightning Attack Effect").SingleOrDefault();
Assert.That(visual, Is.Not.Null);
Assert.That(visual.GetComponentInChildren<SpriteRenderer>(), Is.Not.Null);
}
[Test]
public void LightningStormFinalBranchHitsEveryTargetInItsAuthoredArea()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Lightning, DefenseTowerBranch.Second);
Defense25DEnemy[] targets = SpawnTargets(DefenseTowerKind.Lightning,
tower.Position + new Vector2(.65f, 0f), 4);
tower.StepParityAttacks(0f);
Assert.That(targets.Count(enemy => enemy.Actor.DamageEvents > 0), Is.EqualTo(targets.Length));
GameObject visual = FindNamed("Lightning Attack Effect").SingleOrDefault();
Assert.That(visual, Is.Not.Null);
Assert.That(visual.GetComponentInChildren<SpriteRenderer>(), Is.Not.Null);
}
[Test]
public void FreezeboltFinalBranchLaunchesFiveProjectilesAndHitsAtFlightArrival()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Cold, DefenseTowerBranch.First);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Cold, tower.Position + Vector2.right);
float before = target.Actor.CurrentHealth;
tower.StepParityAttacks(0f);
Assert.That(FindNamed("Tower Projectile").Length, Is.EqualTo(
GameplayConstants.Current.Defense.FreezeboltProjectileCount));
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before));
tower.StepParityAttacks(.179f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before));
tower.StepParityAttacks(.001f);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0));
Assert.That(target.IsStunned, Is.True, "Freezebolt's impact applies its authored stun.");
}
[Test]
public void BlizzardFinalBranchCreatesEightIndependentFlightsAndStaggeredLaunches()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Cold, DefenseTowerBranch.Second);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Cold, tower.Position + Vector2.right);
tower.StepParityAttacks(0f);
GameObject[] shards = FindNamed("Blizzard Shard Flight");
Assert.That(shards.Length, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount * 2),
"Each of the eight shards has a separate main and trail flight.");
Assert.That(shards.Select(item => item.transform.position).Distinct().Count(), Is.GreaterThan(8));
Assert.That(shards.Count(item => item.GetComponentInChildren<SpriteRenderer>().enabled), Is.EqualTo(2),
"Only the first shard and its trail launch at t=0.");
tower.StepParityAttacks(DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds);
Assert.That(shards.Count(item => item != null
&& item.GetComponentInChildren<SpriteRenderer>().enabled), Is.EqualTo(4),
"The second shard launches after the authored 0.04-second interval.");
Vector2 landingOffset = DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
target.Actor.ResetAt(target.Actor.Position + landingOffset);
target.Actor.Visual.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver);
tower.StepParityAttacks(DefenseTowerArtBounds.BlizzardLandingSeconds
- DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds);
tower.StepParityAttacks(.001f);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0),
"A landing wave must resolve on its authored 0.24-second arrival (allowing one millisecond for float accumulation).");
}
[Test]
public void BlizzardLandingUsesItsAuthoredEllipseRatherThanTheCastCircle()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Cold, DefenseTowerBranch.Second);
Vector2 triggerPosition = tower.Position + Vector2.right * .8f;
Defense25DEnemy trigger = SpawnTarget(DefenseTowerKind.Cold, triggerPosition);
Vector2 landingCenter = triggerPosition
+ DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
Defense25DEnemy inside = SpawnTarget(DefenseTowerKind.Cold, landingCenter);
float rx = DefenseTowerArtBounds.BlizzardWaveRadiusX;
float ry = DefenseTowerArtBounds.BlizzardWaveRadiusY;
Vector2 outsidePosition = Vector2.zero;
bool foundOutside = false;
for (float dx = rx * 1.05f; dx < 1.65f; dx += .02f)
{
Vector2 candidate = landingCenter + Vector2.right * dx;
bool overlapsAnyLanding = false;
for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++)
{
Vector2 center = triggerPosition
+ DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f);
Vector2 delta = candidate - center;
if (delta.x * delta.x / (rx * rx) + delta.y * delta.y / (ry * ry) <= 1f)
{
overlapsAnyLanding = true;
break;
}
}
if (overlapsAnyLanding || Vector2.Distance(candidate, triggerPosition) > 1.65f + rx) continue;
outsidePosition = candidate;
foundOutside = true;
break;
}
Assert.That(foundOutside, Is.True, "Fixture needs a point inside cast reach but outside all landing ellipses.");
Defense25DEnemy outside = SpawnTarget(DefenseTowerKind.Cold, outsidePosition);
tower.StepParityAttacks(0f);
tower.StepParityAttacks(DefenseTowerArtBounds.BlizzardLandingSeconds);
Assert.That(inside.Actor.DamageEvents, Is.GreaterThan(0), "The first shard's ellipse must hit its center.");
Assert.That(outside.Actor.DamageEvents, Is.EqualTo(0),
"Being inside the broad cast circle is not sufficient to receive an ellipse landing hit.");
}
[Test]
public void StoneGazeFinalBranchDealsDamageAndStunsOnAttack()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Seismic, DefenseTowerBranch.First);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Seismic, tower.Position + Vector2.right);
tower.StepParityAttacks(0f);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0));
Assert.That(target.IsStunned, Is.True);
Assert.That(FindNamed("Tower Attack Effect").Length, Is.GreaterThan(0),
"The authored Stone Gaze marker is rendered with the attack.");
}
[Test]
public void TemporalFinalBranchDealsDamageAndAppliesItsSlowInsteadOfStun()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Seismic, DefenseTowerBranch.Second);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Seismic, tower.Position + Vector2.right);
tower.StepParityAttacks(0f);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0));
Assert.That(target.IsStunned, Is.False);
var slowFraction = typeof(Defense25DEnemy).GetField("slowFraction",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(slowFraction, Is.Not.Null);
Assert.That((float)slowFraction.GetValue(target), Is.EqualTo(.5f).Within(.001f));
Assert.That(FindNamed("Tower Attack Effect").Length, Is.GreaterThan(0));
}
[Test]
public void OutsideSightAppliesOnceToDamageAndControlDuration()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Seismic, DefenseTowerBranch.First);
world.Player.ResetAt(tower.Position + Vector2.right * 7f);
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Seismic, tower.Position + Vector2.right);
float before = target.Actor.CurrentHealth;
float now = world.GameTime;
Assert.That(tower.SightEfficiency, Is.EqualTo(.7f));
tower.StepParityAttacks(0f);
float expectedDamage = GameplayConstants.Current.Defense.SeismicDamage
* (1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier * 3f) * .7f;
Assert.That(before - target.Actor.CurrentHealth,
Is.EqualTo(DamageCalculator.FinalizeDamage(expectedDamage)));
var stunUntil = typeof(Defense25DEnemy).GetField("stunUntil",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That((float)stunUntil.GetValue(target) - now,
Is.EqualTo(GameplayConstants.Current.Defense.SeismicStunSeconds * 2f * .7f).Within(.001f));
}
[Test]
public void GiantSlimeFinalBranchSummonAttackDealsDamageAndStuns()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Summon, DefenseTowerBranch.First);
Defense25DSummon giant = world.Summons.Single();
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Summon, tower.Position + Vector2.right);
int hitsBefore = target.Actor.DamageEvents;
Assert.That(tower.TryAttackSummon(giant, target), Is.True);
Assert.That(target.Actor.DamageEvents, Is.EqualTo(hitsBefore + 1));
Assert.That(target.IsStunned, Is.True);
Assert.That(giant.name, Does.Contain("GiantSlime"));
}
[Test]
public void ScoutFinalBranchUsesItsAuthoredDelayedFlightBeforeDamage()
{
Defense25DTower tower = BuildT4(DefenseTowerKind.Summon, DefenseTowerBranch.Second);
Defense25DSummon scout = world.Summons.First();
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Summon, tower.Position + Vector2.right);
float before = target.Actor.CurrentHealth;
Assert.That(tower.TryAttackSummon(scout, target), Is.True);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before));
Assert.That(FindNamed("Tower Projectile").Length, Is.EqualTo(1));
tower.StepParityAttacks(.48f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before), "The projectile launches after 0.48 seconds.");
tower.StepParityAttacks(.159f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before));
tower.StepParityAttacks(.001f);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0), "Scout flight arrival applies the hit.");
}
[Test]
public void ImbuingAtSeventyPercentSightAppliesItsOriginalIntervalMultiplierOnce()
{
Defense25DTower support = BuildT4At(0, DefenseTowerKind.Empowering, DefenseTowerBranch.First);
Defense25DTower attacker = BuildT4At(4, DefenseTowerKind.Lightning, DefenseTowerBranch.First);
world.Player.ResetAt(new Vector2(0f, -2f));
Assert.That(support.SightEfficiency, Is.EqualTo(.7f));
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Lightning,
attacker.Position + Vector2.right);
world.TowerManager.Step(0f);
float expected = 1f - (1f - GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier) * .7f;
Assert.That(attacker.AttackIntervalMultiplier, Is.EqualTo(expected).Within(.001f));
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0),
"Imbuing must be exercised through a real supported tower hit.");
}
[TestCase(4.9f, 1f)]
[TestCase(-2f, .7f)]
public void TwoSmotheringSupportsStackAfterOneMaximumBaseBonus(float playerZ, float sight)
{
BuildT4At(0, DefenseTowerKind.Empowering, DefenseTowerBranch.Second);
BuildT4At(3, DefenseTowerKind.Empowering, DefenseTowerBranch.Second);
Defense25DTower attacker = BuildTowerAt(4, DefenseTowerKind.Lightning);
world.Player.ResetAt(new Vector2(0f, playerZ));
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Lightning, attacker.Position);
float before = target.Actor.CurrentHealth;
world.TowerManager.Step(0f);
float baseDamage = GameplayConstants.Current.Defense.LightningDamage;
float baseSupport = 1f + GameplayConstants.Current.Defense.SupportDamageBonusPerTier * 4f * sight;
float smothering = 1f + GameplayConstants.Current.Defense.SmotheringDamageBonus * sight;
float expected = baseDamage * sight * baseSupport * smothering * smothering;
Assert.That(target.Actor.CurrentHealth,
Is.EqualTo(before - DamageCalculator.FinalizeDamage(expected)),
"Original policy applies one max tier support bonus and each in-range Smothering multiplier.");
}
[Test]
public void FirstInRangeImbuingSupportOwnsAttackIntervalWhenTwoSupportsOverlap()
{
BuildT4At(0, DefenseTowerKind.Empowering, DefenseTowerBranch.First);
BuildT4At(3, DefenseTowerKind.Empowering, DefenseTowerBranch.First);
Defense25DTower attacker = BuildTowerAt(4, DefenseTowerKind.Lightning);
world.Player.ResetAt(new Vector2(-3.25f, 4.9f));
Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Lightning,
attacker.Position + Vector2.right);
world.TowerManager.Step(0f);
Assert.That(attacker.AttackIntervalMultiplier,
Is.EqualTo(GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier).Within(.001f),
"The original controller returns the first in-range T4 Imbuing support.");
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0));
}
private Defense25DTower BuildT4(DefenseTowerKind kind, DefenseTowerBranch branch) =>
BuildT4At(0, kind, branch);
private Defense25DTower BuildT4At(int pad, DefenseTowerKind kind, DefenseTowerBranch branch)
{
Defense25DTower tower = BuildTowerAt(pad, kind);
world.TowerManager.AddCurrency(1000);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, branch), Is.True);
Assert.That(tower.Tier, Is.EqualTo(4));
Assert.That(tower.Branch, Is.EqualTo(branch));
return tower;
}
private Defense25DTower BuildTowerAt(int pad, DefenseTowerKind kind)
{
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad));
world.TowerManager.AddCurrency(1000);
Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True, $"pad={pad} {kind}");
Defense25DTower tower = world.TowerManager.GetTowerAtPad(pad);
tower.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver);
return tower;
}
private Defense25DEnemy SpawnTarget(DefenseTowerKind kind, Vector2 position, bool immobilize = false)
{
Defense25DEnemy target = null;
for (int i = 0; i < GameplayConstants.Current.Defense.PortalEnemyCount * 2; i++)
{
Defense25DEnemy candidate = world.SpawnNextEnemy();
if (candidate == null) break;
if (world.IsTowerImmuneToEnemy(kind, candidate)) continue;
target = candidate;
break;
}
Assert.That(target, Is.Not.Null, $"No lane target is eligible for {kind}.");
target.Actor.ResetAt(position);
if (immobilize) target.ApplyStun(30f);
target.Actor.Visual.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver);
Physics.SyncTransforms();
return target;
}
private Defense25DEnemy[] SpawnTargets(DefenseTowerKind kind, Vector2 center, int count)
{
var targets = new System.Collections.Generic.List<Defense25DEnemy>();
for (int i = 0; i < count; i++)
targets.Add(SpawnTarget(kind, center + new Vector2((i - (count - 1) * .5f) * .18f, 0f)));
return targets.ToArray();
}
private void StepWorld(int steps)
{
for (int i = 0; i < steps; i++)
world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver);
}
private float NextDamage(Defense25DEnemy target, int maxFrames = 100)
{
float before = target.Actor.CurrentHealth;
int damageEvents = target.Actor.DamageEvents;
for (int i = 0; i < maxFrames && target.Actor.DamageEvents == damageEvents; i++)
StepWorld(1);
Assert.That(target.Actor.DamageEvents, Is.GreaterThan(damageEvents),
"The next authored tower pulse must reach the selected target.");
return before - target.Actor.CurrentHealth;
}
private static GameObject[] FindNamed(string name) =>
Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None)
.Where(item => item.scene.IsValid() && item.name == name).ToArray();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 64a81f573c3748b3bfde39c36b1a7d27
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,174 @@
using System.Reflection;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class Defense25DTowerBodyAnimationPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private EnemyDefinition highHealthEnemy;
[SetUp]
public void SetUp()
{
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
EnemyDefinition sourceEnemy = settings.Enemy;
highHealthEnemy = Object.Instantiate(sourceEnemy);
highHealthEnemy.name = sourceEnemy.name + " Tower Body Animation Test";
highHealthEnemy.Configure(sourceEnemy.Kind, sourceEnemy.AttackShape, 10000f,
sourceEnemy.MoveSpeed, sourceEnemy.AttackDamage, sourceEnemy.WarningDuration,
sourceEnemy.ActiveDuration, sourceEnemy.RecoveryDuration, sourceEnemy.AttackRange,
sourceEnemy.PreferredRange, sourceEnemy.AttackLength, sourceEnemy.AttackWidth,
sourceEnemy.AttackRadius, sourceEnemy.ExperienceValue, sourceEnemy.AttackAnimationLeadTime);
highHealthEnemy.ConfigureRole(sourceEnemy.Role);
highHealthEnemy.ConfigureAttackPatterns(sourceEnemy.AttackPatterns);
settings.Enemy = highHealthEnemy;
world = new GameObject("2.5D tower body animation test").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
}
[TearDown]
public void TearDown()
{
if (world != null) Object.DestroyImmediate(world.gameObject);
if (highHealthEnemy != null) Object.DestroyImmediate(highHealthEnemy);
if (settings != null) Object.DestroyImmediate(settings);
}
[Test]
public void SuccessfulAttackAdvancesAuthoredFramesThenReturnsToIdle()
{
Defense25DTower tower = BuildTower(DefenseTowerKind.Direct, 0);
Defense25DEnemy target = SpawnTarget(tower);
Sprite[] attack = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(tower.Kind, tower.Tier, "Attack01"));
Sprite[] idle = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(tower.Kind, tower.Tier, "Idle"));
SpriteRenderer body = tower.transform.Find("Tower billboard").GetComponent<SpriteRenderer>();
tower.Step(0f);
Assert.That(tower.IsBodyAttacking, Is.True);
Assert.That(body.sprite, Is.EqualTo(attack[0]));
tower.Step(.125f);
Assert.That(tower.BodyAnimationFrame, Is.EqualTo(1));
Assert.That(body.sprite, Is.EqualTo(attack[1]));
target.Actor.ResetAt(tower.Position + Vector2.right
* (GameplayConstants.Current.Defense.TowerRange + 2f));
Physics.SyncTransforms();
tower.Step(.625f);
Assert.That(tower.IsBodyAttacking, Is.False);
Assert.That(tower.BodyAnimationFrame, Is.EqualTo(0));
Assert.That(body.sprite, Is.EqualTo(idle[0]));
Assert.That(target.IsAvailable, Is.True);
}
[Test]
public void FailedShotRestoresThePriorBodyAnimationState()
{
Defense25DTower tower = BuildTower(DefenseTowerKind.Lightning, 0);
tower.Step(.125f);
SpriteRenderer body = tower.transform.Find("Tower billboard").GetComponent<SpriteRenderer>();
Sprite previousSprite = body.sprite;
bool previousFlip = body.flipX;
int previousFrame = tower.BodyAnimationFrame;
bool previousAttacking = tower.IsBodyAttacking;
Defense25DEnemy target = SpawnTarget(tower);
target.gameObject.layer = 0;
Physics.SyncTransforms();
tower.StepParityAttacks(0f);
Assert.That(tower.BodyAnimationFrame, Is.EqualTo(previousFrame));
Assert.That(tower.IsBodyAttacking, Is.EqualTo(previousAttacking));
Assert.That(body.flipX, Is.EqualTo(previousFlip));
Assert.That(body.sprite, Is.EqualTo(previousSprite));
}
[Test]
public void ProjectileLaunchMatchesRenderedStaffTipThroughPitchedCamera()
{
Defense25DTower tower = BuildTower(DefenseTowerKind.Direct, 0);
SpawnTarget(tower);
tower.Step(0f);
SpriteRenderer body = tower.transform.Find("Tower billboard").GetComponent<SpriteRenderer>();
Camera camera = CreatePitchedCamera(body.transform.position);
Vector3 staffTip = tower.ParityStaffTipVisualWorldPosition;
GameObject projectile = GameObject.Find("Tower Projectile");
Assert.That(projectile, Is.Not.Null);
Vector3 projectedStaffTip = camera.WorldToScreenPoint(staffTip);
Vector3 projectedLaunch = camera.WorldToScreenPoint(projectile.transform.position);
Assert.That(projectedStaffTip.z, Is.GreaterThan(0f));
Assert.That(projectedLaunch.z, Is.GreaterThan(0f));
Assert.That(Vector2.Distance(projectedStaffTip, projectedLaunch), Is.LessThan(.01f),
"The projectile's rendered launch point must coincide with the active frame's staff tip.");
}
[Test]
public void SupportAuraUsesMaskFrameAndTracksBillboardTransform()
{
Defense25DTower support = BuildTower(DefenseTowerKind.Empowering, 0);
Defense25DTower receiver = BuildTower(DefenseTowerKind.Direct, 4);
Assert.That(Vector2.Distance(support.Position, receiver.Position),
Is.LessThanOrEqualTo(GameplayConstants.Current.Defense.SupportRadius));
world.TowerManager.Step(0f);
SpriteRenderer body = receiver.transform.Find("Tower billboard").GetComponent<SpriteRenderer>();
SpriteRenderer aura = receiver.transform.Find("Support Aura Art").GetComponent<SpriteRenderer>();
Assert.That(receiver.BodySupportAuraTier, Is.EqualTo(support.Tier));
Assert.That(aura.enabled, Is.True);
Assert.That(aura.sprite.name, Is.EqualTo(body.sprite.name));
Assert.That(aura.flipX, Is.EqualTo(body.flipX));
Assert.That(Vector3.Distance(aura.transform.position, body.transform.position), Is.LessThan(.001f));
Assert.That(Vector3.Distance(aura.transform.localScale, body.transform.localScale), Is.LessThan(.001f));
}
private Defense25DTower BuildTower(DefenseTowerKind kind, int pad)
{
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad));
world.TowerManager.AddCurrency(1000);
Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True,
$"pad={pad} kind={kind}");
Defense25DTower tower = world.TowerManager.GetTowerAtPad(pad);
tower.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver);
return tower;
}
private Defense25DEnemy SpawnTarget(Defense25DTower tower)
{
Defense25DEnemy target = Enumerable.Range(0,
GameplayConstants.Current.Defense.PortalEnemyCount * 2)
.Select(_ => world.SpawnNextEnemy())
.First(enemy => enemy != null && !world.IsTowerImmuneToEnemy(tower.Kind, enemy));
target.Actor.ResetAt(tower.Position + Vector2.right);
target.ApplyStun(30f);
Physics.SyncTransforms();
return target;
}
private Camera CreatePitchedCamera(Vector3 target)
{
GameObject cameraObject = new("Staff tip launch projection camera");
Camera camera = cameraObject.AddComponent<Camera>();
camera.orthographic = true;
camera.transform.rotation = Quaternion.Euler(settings.CameraPitch, 0f, 0f);
camera.transform.position = target - camera.transform.forward * 10f;
camera.transform.SetParent(world.transform, true);
return camera;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 84c386504702483db01d1fc7617fa2ec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,403 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Constants;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEngine;
using UnityEditor;
using UnityEngine.TestTools;
namespace BumpCombat.Tests
{
public sealed class Defense25DTowerPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
[UnitySetUp]
public IEnumerator SetUp()
{
settings = UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
world = new GameObject("2.5D tower test").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
yield return null;
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) UnityEngine.Object.Destroy(world.gameObject);
if (settings != null) UnityEngine.Object.Destroy(settings);
yield return null;
}
[Test]
public void EachTowerFamilyCanBeBuiltOnAnEmptyPadWithItsConfiguredCost()
{
Type managerType = typeof(Defense25DComparison).Assembly.GetType(
"BumpCombat.Defense.Comparison.Defense25DTowerManager");
Assert.That(managerType, Is.Not.Null,
"The 2.5D run needs a tower manager that owns pads, construction currency, and tower instances.");
Component manager = world.GetComponent(managerType);
Assert.That(manager, Is.Not.Null);
MethodInfo build = managerType.GetMethod("TryBuildTowerAtPad",
new[] { typeof(int), typeof(DefenseTowerKind) });
MethodInfo getTower = managerType.GetMethod("GetTowerAtPad", new[] { typeof(int) });
Assert.That(build, Is.Not.Null);
Assert.That(getTower, Is.Not.Null);
foreach (DefenseTowerKind kind in new[]
{
DefenseTowerKind.Direct,
DefenseTowerKind.Summon,
DefenseTowerKind.Lightning,
DefenseTowerKind.Cold,
DefenseTowerKind.Seismic,
DefenseTowerKind.Empowering,
})
{
world.ResetRun();
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
Physics.SyncTransforms();
Assert.That(build.Invoke(manager, new object[] { 0, kind }), Is.EqualTo(true), kind.ToString());
object tower = getTower.Invoke(manager, new object[] { 0 });
Assert.That(tower, Is.Not.Null, kind.ToString());
Type towerType = tower.GetType();
Assert.That(towerType.GetProperty("Kind").GetValue(tower), Is.EqualTo(kind));
Assert.That(towerType.GetProperty("Tier").GetValue(tower), Is.EqualTo(1));
Assert.That(towerType.GetProperty("Branch").GetValue(tower),
Is.EqualTo(DefenseTowerBranch.None));
Assert.That(managerType.GetProperty("ConstructionCurrency").GetValue(manager),
Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency - BuildCost(kind)));
}
}
[Test]
public void TowerEfficiencyIsZeroUntilARealTowerIsBuilt()
{
world.Player.ResetAt(world.Settings.TowerPosition);
Assert.That(world.TowerEfficiency, Is.EqualTo(0f));
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
Assert.That(world.TowerEfficiency, Is.EqualTo(1f));
}
[Test]
public void ImbuingIntervalUsesFirstConstructedSupportEvenWhenPadOrderIsReversed()
{
Defense25DTowerManager manager = world.TowerManager;
manager.AddCurrency(10000);
Defense25DTower firstBuilt = BuildTower(4, DefenseTowerKind.Empowering);
UpgradeToTierFourImbuing(manager, 4);
Defense25DTower secondBuilt = BuildTower(0, DefenseTowerKind.Empowering);
UpgradeToTierFourImbuing(manager, 0);
Defense25DTower receiver = BuildTower(2, DefenseTowerKind.Direct);
SetTowerPosition(firstBuilt, Vector2.zero);
SetTowerPosition(secondBuilt, new Vector2(6.5f, 0f));
SetTowerPosition(receiver, new Vector2(3.25f, 0f));
world.Player.ResetAt(Vector2.zero);
manager.Step(0f);
Assert.That(manager.TowersInConstructionOrder[0], Is.SameAs(firstBuilt));
Assert.That(manager.TowersInConstructionOrder[1], Is.SameAs(secondBuilt));
Assert.That(receiver.AttackIntervalMultiplier,
Is.EqualTo(GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier).Within(.001f),
"The first built in-range Imbuing tower wins before the later, out-of-sight source.");
}
[Test]
public void NearestTowerBuildUsesTheClosestEmptyPadWithinInteractionRange()
{
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0);
world.Player.ResetAt(pad);
MethodInfo buildNearest = typeof(Defense25DComparison).GetMethod("TryBuildNearestTower",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(buildNearest, Is.Not.Null);
Assert.That(buildNearest.Invoke(world, new object[] { DefenseTowerKind.Direct }), Is.EqualTo(true));
Assert.That(world.TowerManager.GetTowerAtPad(0), Is.Not.Null);
}
[Test]
public void EveryTowerFamilyCanReachBothTierFourBranches()
{
Type managerType = typeof(Defense25DComparison).Assembly.GetType(
"BumpCombat.Defense.Comparison.Defense25DTowerManager");
Component manager = world.GetComponent(managerType);
MethodInfo build = managerType.GetMethod("TryBuildTowerAtPad",
new[] { typeof(int), typeof(DefenseTowerKind) });
MethodInfo upgrade = managerType.GetMethod("TryUpgradeTowerAtPad",
new[] { typeof(int), typeof(DefenseTowerBranch) });
MethodInfo addCurrency = managerType.GetMethod("AddCurrency");
MethodInfo getTower = managerType.GetMethod("GetTowerAtPad");
foreach (DefenseTowerKind kind in System.Enum.GetValues(typeof(DefenseTowerKind)))
{
foreach (DefenseTowerBranch branch in new[] { DefenseTowerBranch.First, DefenseTowerBranch.Second })
{
world.ResetRun();
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
addCurrency.Invoke(manager, new object[] { 1000 });
Assert.That(build.Invoke(manager, new object[] { 0, kind }), Is.EqualTo(true), kind.ToString());
Assert.That(upgrade.Invoke(manager, new object[] { 0, DefenseTowerBranch.None }), Is.EqualTo(true));
Assert.That(upgrade.Invoke(manager, new object[] { 0, DefenseTowerBranch.None }), Is.EqualTo(true));
Assert.That(upgrade.Invoke(manager, new object[] { 0, branch }), Is.EqualTo(true));
object tower = getTower.Invoke(manager, new object[] { 0 });
Assert.That(tower.GetType().GetProperty("Tier").GetValue(tower), Is.EqualTo(4));
Assert.That(tower.GetType().GetProperty("Branch").GetValue(tower), Is.EqualTo(branch));
if (kind == DefenseTowerKind.Summon)
{
int expected = branch == DefenseTowerBranch.First ? 1
: GameplayConstants.Current.Defense.SummonUnitCount + 4;
Assert.That(world.Summons.Count, Is.EqualTo(expected), branch.ToString());
}
}
}
}
[Test]
public void DirectFireballDealsDamageOnlyAfterItsSourceFlightDuration()
{
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
Defense25DEnemy target = null;
for (int i = 0; i < 2; i++)
{
Defense25DEnemy candidate = world.SpawnNextEnemy();
if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate))
{
target = candidate;
break;
}
}
Assert.That(target, Is.Not.Null);
target.Actor.ResetAt(tower.Position + Vector2.right);
Physics.SyncTransforms();
float health = target.Actor.CurrentHealth;
tower.StepParityAttacks(0f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "launch must not apply damage");
tower.StepParityAttacks(.17f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "projectile is still in flight");
tower.StepParityAttacks(.01f);
Assert.That(target.Actor.CurrentHealth, Is.LessThan(health), "impact callback applies damage");
}
[Test]
public void ScoutAttackDamagesAfterOriginalLaunchAndFlightTiming()
{
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0);
world.Player.ResetAt(pad);
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
world.TowerManager.AddCurrency(1000);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True);
Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.Second), Is.True);
Defense25DSummon scout = world.Summons[0];
Defense25DEnemy target = world.SpawnNextEnemy();
if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, target))
target = world.SpawnNextEnemy();
target.Actor.ResetAt(tower.Position + Vector2.right);
Physics.SyncTransforms();
float health = target.Actor.CurrentHealth;
Assert.That(tower.TryAttackSummon(scout, target), Is.True);
world.TowerManager.Step(.47f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the scout's projectile has not launched");
world.TowerManager.Step(.01f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile has only just launched");
world.TowerManager.Step(.159f);
Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile is still in flight");
world.TowerManager.Step(.001f);
Assert.That(target.Actor.CurrentHealth, Is.LessThan(health));
}
[UnityTest]
public IEnumerator OwnedSummonsKeepAttackingTheirTowerCommandTarget()
{
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0);
world.Player.ResetAt(pad);
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
Defense25DSummon summon = world.Summons[0];
Defense25DEnemy target = world.SpawnNextEnemy();
if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, target))
target = world.SpawnNextEnemy();
target.Actor.ResetAt(summon.Actor.Position + Vector2.left * .9f);
target.ApplyStun(2f);
Physics.SyncTransforms();
float health = target.Actor.CurrentHealth;
tower.Step(0f);
summon.Step(0f);
Assert.That(summon.IsAttacking, Is.True);
for (int i = 0; i < 60 && target.Actor.CurrentHealth >= health; i++)
yield return new WaitForFixedUpdate();
Assert.That(target.Actor.CurrentHealth, Is.LessThan(health));
}
[UnityTest]
public IEnumerator BothPortalsSupplyTheirOwnRoutesAndRewardsGateVictory()
{
int addedArtifacts = 0;
foreach (ActiveArtifactDefinition artifact in world.Loadout.Catalog)
{
if (world.Loadout.TryAddArtifact(artifact)) addedArtifacts++;
if (addedArtifacts == 3) break;
}
Assert.That(addedArtifacts, Is.EqualTo(3), "The route/reward fixture needs three eligible owned artifacts.");
int expectedPerPortal = GameplayConstants.Current.Defense.PortalEnemyCount;
int totalExpected = expectedPerPortal * 2;
List<Defense25DEnemy> supplied = new();
for (int i = 0; i < totalExpected; i++)
{
Defense25DEnemy enemy = world.SpawnNextEnemy();
Assert.That(enemy, Is.Not.Null, $"supply {i + 1}/{totalExpected}");
supplied.Add(enemy);
}
Assert.That(world.SpawnNextEnemy(), Is.Null);
PropertyInfo laneProperty = typeof(Defense25DEnemy).GetProperty("Lane");
Assert.That(laneProperty, Is.Not.Null);
for (int i = 0; i < supplied.Count; i++)
{
int lane = (int)laneProperty.GetValue(supplied[i]);
Assert.That(lane, Is.EqualTo(i % 2), $"alternating portal supply #{i}");
Assert.That(Vector2.Distance(supplied[i].Actor.Position,
GameplayConstants.Current.Defense.GetPortalPosition(lane)), Is.LessThan(.001f));
}
foreach (Defense25DEnemy enemy in supplied)
enemy.Actor.TakeDamage(10000f, Vector2.zero);
yield return new WaitForSeconds(.1f);
Assert.That(world.IsRunning, Is.True, "both field rewards must be collected before victory");
MethodInfo collect = typeof(Defense25DComparison).GetMethod("TryCollectRewardAtLane");
Assert.That(collect, Is.Not.Null);
for (int lane = 0; lane < 2; lane++)
{
Vector2 pickup = GameplayConstants.Current.Defense.GetPortalPosition(lane) + Vector2.up * 1.2f;
world.Player.ResetAt(pickup);
Assert.That(collect.Invoke(world, new object[] { lane }), Is.EqualTo(true), $"reward lane {lane}");
Assert.That(world.IsRunning, Is.True, "The run must remain active while an earned relic choice is pending.");
Assert.That(world.HasPendingFieldRelicReward, Is.True, $"field relic choice lane {lane}");
Assert.That(world.Paused, Is.True, "The earned field choice must pause the run before the next pickup.");
Assert.That(world.ResolveFieldRelicChoice(world.PendingFieldRelicChoices[0]), Is.True,
$"resolve field relic choice lane {lane}");
}
Assert.That(world.IsRunning, Is.False);
Assert.That(typeof(Defense25DComparison).GetProperty("RunCleared").GetValue(world), Is.EqualTo(true));
}
[Test]
public void SummonVariantsUseAuditedCoreScaleForVisualColliderAndFoot()
{
world.TowerManager.AddCurrency(10000);
Defense25DTower slimeTower = BuildTower(1, DefenseTowerKind.Summon);
AssertSummonVariant(slimeTower, world.Settings.SummonVisualScale);
UpgradeToTierFourImbuing(world.TowerManager, 1);
AssertSummonVariant(slimeTower, 1.55f);
Defense25DTower scoutTower = BuildTower(2, DefenseTowerKind.Summon);
UpgradeToTierFourImbuing(world.TowerManager, 2, DefenseTowerBranch.Second);
AssertSummonVariant(scoutTower, 1.15f);
}
[UnityTest]
public IEnumerator TowerImmunityIsResolvedForEachPortalLane()
{
Defense25DEnemy upper = world.SpawnNextEnemy();
Defense25DEnemy lower = world.SpawnNextEnemy();
Assert.That(upper.Lane, Is.EqualTo(0));
Assert.That(lower.Lane, Is.EqualTo(1));
var tuning = GameplayConstants.Current.Defense;
Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(0), upper), Is.True);
Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(1), lower), Is.True);
Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(0), lower), Is.False);
Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(1), upper), Is.False);
yield return null;
}
private static int BuildCost(DefenseTowerKind kind)
{
var tuning = GameplayConstants.Current.Defense;
return kind switch
{
DefenseTowerKind.Direct => tuning.DirectTowerCost,
DefenseTowerKind.Summon => tuning.SummonTowerCost,
DefenseTowerKind.Lightning => tuning.LightningTowerCost,
DefenseTowerKind.Cold => tuning.ColdTowerCost,
DefenseTowerKind.Seismic => tuning.SeismicTowerCost,
DefenseTowerKind.Empowering => tuning.EmpoweringTowerCost,
_ => -1,
};
}
private Defense25DTower BuildTower(int pad, DefenseTowerKind kind)
{
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad));
Physics.SyncTransforms();
Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True, $"pad={pad} kind={kind}");
return world.TowerManager.GetTowerAtPad(pad);
}
private static void UpgradeToTierFourImbuing(Defense25DTowerManager manager, int pad,
DefenseTowerBranch finalBranch = DefenseTowerBranch.First)
{
Assert.That(manager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True);
Assert.That(manager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True);
Assert.That(manager.TryUpgradeTowerAtPad(pad, finalBranch), Is.True);
}
private void SetTowerPosition(Defense25DTower tower, Vector2 position)
{
typeof(Defense25DTower).GetProperty(nameof(Defense25DTower.Position))
.SetValue(tower, position);
tower.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY);
}
private void AssertSummonVariant(Defense25DTower tower, float scale)
{
Defense25DSummon[] units = world.Summons.Where(unit => unit.Owner == tower).ToArray();
Assert.That(units.Length, Is.EqualTo(tower.TargetSummonCount));
float coreDiameter = scale > 1.4f
? Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels
: scale > world.Settings.SummonVisualScale
? Defense25DComparisonSettings.LavaSlimeBodyCoreDiameterPixels
: Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels;
float radius = coreDiameter * scale / Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f;
foreach (Defense25DSummon summon in units)
{
Defense25DSprite visual = summon.Actor.Visual;
Assert.That(visual.VisualScale, Is.EqualTo(scale).Within(.001f));
float expectedFootOffset = scale > 1.4f ? 0f
: world.Settings.SummonFootOffset * scale / world.Settings.SummonVisualScale;
Assert.That(visual.FootVisualOffset, Is.EqualTo(expectedFootOffset).Within(.001f));
Assert.That(summon.Actor.Radius, Is.EqualTo(radius).Within(.001f));
Assert.That(summon.Actor.BodyCollider.radius, Is.EqualTo(radius).Within(.001f));
Assert.That(summon.Actor.BodyCollider.height, Is.EqualTo(2f * radius).Within(.001f));
Assert.That(visual.transform.Find("Ground shadow"), Is.Null,
"The comparison actor keeps its existing authored sprite and has no added ground-shadow cylinder.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 83a28477b5f458c4f8368ae7760c9a67
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
using System;
using System.Collections;
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Defense;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace BumpCombat.PlayModeTests
{
public sealed class DefenseSummonCrowdAttackPlayModeTests
{
private const float RepeatedHitTimeoutSeconds = 12f;
private static readonly BindingFlags PrivateInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
[UnityTest]
public IEnumerator T1ToT3DefaultShowcaseSummonsRepeatedlyHitTheirOwnDummy()
{
MethodInfo startRun = typeof(DefensePrototypePlayModeTests).GetMethod(
"StartDefenseRun", PrivateInstance);
Assert.That(startRun, Is.Not.Null);
IEnumerator setup = (IEnumerator)startRun.Invoke(
new DefensePrototypePlayModeTests(), new object[] { true });
yield return setup;
DefensePrototypeController controller = DefensePrototypeController.Instance;
DefenseTower tower = FindSummonTower();
DefenseRouteAgent target = FindSummonDummy();
Assert.That(controller.IsTowerHitTestLayout, Is.True);
Assert.That(tower, Is.Not.Null);
Assert.That(target, Is.Not.Null);
Assert.That(Vector2.Distance(tower.transform.position,
controller.Tuning.GetBuildPadPosition(tower.PadIndex)), Is.LessThan(0.001f));
Assert.That(Vector2.Distance(target.transform.position,
DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon)),
Is.LessThan(0.001f));
foreach (DefenseTower other in UnityEngine.Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None))
{
if (other != tower) other.enabled = false;
}
SetEnemyCurrentHealth(target.Enemy, 10000f);
tower.enabled = true;
float readyDeadline = Time.realtimeSinceStartup + 8f;
while (Time.realtimeSinceStartup < readyDeadline
&& (target.Enemy.State == EnemyState.Spawn || tower.CommandTarget != target))
{
yield return new WaitForFixedUpdate();
}
Assert.That(target.Enemy.State, Is.Not.EqualTo(EnemyState.Spawn));
Assert.That(tower.CommandTarget, Is.SameAs(target));
Assert.That(tower.Tier, Is.EqualTo(1));
Sprite[] attackFrames = DefenseTowerArt.Load("Summons/Slime/Attack01");
FieldInfo appliedField = typeof(DefenseSummonUnit).GetField(
"attackDamageApplied", PrivateInstance);
Assert.That(appliedField, Is.Not.Null);
for (int tier = 1; tier <= 3; tier++)
{
if (tier > 1)
{
Assert.That(tower.Upgrade(DefenseTowerBranch.None), Is.True);
yield return null;
}
Assert.That(tower.Tier, Is.EqualTo(tier));
Assert.That(tower.CommandTarget, Is.SameAs(target));
Assert.That(controller.Tuning.SummonBodySlamImpactFrame, Is.EqualTo(3));
Assert.That(controller.Tuning.SummonBodySlamSpeedMultiplier, Is.EqualTo(2f));
Assert.That(controller.Tuning.SummonBodySlamContactMargin, Is.EqualTo(0.06f));
DefenseSummonUnit[] units = tower.GetComponentsInChildren<DefenseSummonUnit>();
Assert.That(units.Length, Is.EqualTo(controller.Tuning.SummonUnitCount + tier - 1));
for (int i = 0; i < units.Length; i++)
{
for (int j = i + 1; j < units.Length; j++)
{
Assert.That(Physics2D.GetIgnoreCollision(
units[i].BodyCollider, units[j].BodyCollider), Is.False,
"Summon bodies must keep colliding while attack flow recovers.");
}
}
float maximumHit = controller.Tuning.SummonDamage
* (1f + controller.Tuning.TowerDamageBonusPerTier * (tier - 1));
int hitSteps = 0;
float deadline = Time.realtimeSinceStartup + RepeatedHitTimeoutSeconds;
while (hitSteps < 2 && Time.realtimeSinceStartup < deadline
&& !target.Enemy.IsDead)
{
float healthBefore = target.Enemy.CurrentHealth;
bool[] appliedBefore = new bool[units.Length];
for (int i = 0; i < units.Length; i++)
appliedBefore[i] = (bool)appliedField.GetValue(units[i]);
yield return new WaitForFixedUpdate();
float damage = healthBefore - target.Enemy.CurrentHealth;
if (damage <= 0f) continue;
int resolvedAttacks = 0;
bool impactFrameVisible = false;
for (int i = 0; i < units.Length; i++)
{
if (!appliedBefore[i] && (bool)appliedField.GetValue(units[i]))
resolvedAttacks++;
int frame = Array.IndexOf(
attackFrames, units[i].GetComponent<SpriteRenderer>().sprite);
impactFrameVisible |= frame >= controller.Tuning.SummonBodySlamImpactFrame;
}
Assert.That(resolvedAttacks, Is.GreaterThan(0),
"Every HP change must match a body-slam resolution in that physics step.");
Assert.That(damage, Is.LessThanOrEqualTo(
resolvedAttacks * maximumHit + 0.01f),
"Each summon must damage at most once during one attack sequence.");
Assert.That(impactFrameVisible, Is.True,
"Damage must begin at Attack01 frame 3.");
hitSteps++;
Debug.Log($"SUMMON_CROWD T{tier} hitStep={hitSteps} damage={damage:F2}");
}
Assert.That(hitSteps, Is.GreaterThanOrEqualTo(2),
$"The real default T{tier} pack must land repeated hits without moving any unit or target. "
+ BuildBlockerDiagnostics(tower, target, units, attackFrames));
}
}
private static string BuildBlockerDiagnostics(
DefenseTower tower,
DefenseRouteAgent target,
DefenseSummonUnit[] units,
Sprite[] attackFrames)
{
Collider2D targetCollider = target.Enemy.GetComponent<Collider2D>();
System.Text.StringBuilder result = new();
result.AppendLine($"tower={tower.transform.position} target={target.transform.position}");
foreach (DefenseSummonUnit unit in units)
{
Vector2 center = unit.BodyCollider.bounds.center;
Vector2 delta = (Vector2)target.transform.position - center;
float gap = Vector2.Distance(center, targetCollider.ClosestPoint(center))
- unit.BodyCollider.bounds.extents.x;
int frame = Array.IndexOf(
attackFrames, unit.GetComponent<SpriteRenderer>().sprite);
result.Append($"{unit.name}[{unit.SlotIndex}] pos={center} gap={gap:F3} frame={frame} cast=");
if (delta.sqrMagnitude > 0.0001f)
{
ContactFilter2D filter = ContactFilter2D.noFilter;
filter.useTriggers = false;
RaycastHit2D[] hits = new RaycastHit2D[16];
int count = unit.BodyCollider.Cast(
delta.normalized, filter, hits, delta.magnitude);
for (int i = 0; i < count; i++)
{
if (i > 0) result.Append(",");
result.Append($"{hits[i].collider.name}@{hits[i].distance:F3}");
}
}
result.AppendLine();
}
return result.ToString();
}
private static DefenseTower FindSummonTower()
{
foreach (DefenseTower tower in UnityEngine.Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None))
if (tower.Kind == DefenseTowerKind.Summon && tower.PadIndex == 1)
return tower;
return null;
}
private static DefenseRouteAgent FindSummonDummy()
{
foreach (DefenseRouteAgent agent in UnityEngine.Object.FindObjectsByType<DefenseRouteAgent>(
FindObjectsSortMode.None))
if (agent.Enemy != null && agent.Enemy.name == "Tower Test Dummy Summon")
return agent;
return null;
}
private static void SetEnemyCurrentHealth(EnemyController enemy, float health)
{
FieldInfo field = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", PrivateInstance);
Assert.That(field, Is.Not.Null);
field.SetValue(enemy, health);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 83054d7ca59e3ad4381a918d748d5fc9
@@ -0,0 +1,990 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Defense;
using BumpCombat.Enemies;
using BumpCombat.Progression;
using BumpCombat.Player;
using BumpCombat.UI;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace BumpCombat.PlayModeTests
{
public sealed class DefenseTowerVisualPlayModeTests
{
private const string SceneName = "CombatPrototype";
private const string StartupSelectionPanelName = "Artifact Reward Selection";
private const float ReadyTimeoutSeconds = 8f;
private float originalTimeScale;
private Camera captureCamera;
private ArenaCameraFollow captureCameraFollow;
private Vector3 originalCameraPosition;
private float originalCameraOrthographicSize;
private bool originalCameraFollowEnabled;
private bool cameraStateSaved;
#if UNITY_EDITOR
private bool originalGrantCatalogForTests;
private bool originalForceProductionModeForTests;
#endif
[SetUp]
public void SetUp()
{
originalTimeScale = Time.timeScale;
Time.timeScale = 1f;
DefensePrototypeController.CancelTowerHitTestRun();
#if UNITY_EDITOR
originalGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
originalForceProductionModeForTests = RunManager.ForceProductionModeForTests;
ArtifactRewardController.GrantCatalogForTests = false;
RunManager.ForceProductionModeForTests = false;
#endif
}
[TearDown]
public void TearDown()
{
DefensePrototypeController.CancelTowerHitTestRun();
RunManager runManager = RunManager.Instance;
if (runManager != null) runManager.ClosePause();
GameObject selectionPanel = GameObject.Find(StartupSelectionPanelName);
if (selectionPanel != null) selectionPanel.SetActive(false);
#if UNITY_EDITOR
ArtifactRewardController.GrantCatalogForTests = originalGrantCatalogForTests;
RunManager.ForceProductionModeForTests = originalForceProductionModeForTests;
#endif
if (cameraStateSaved && captureCamera != null)
{
captureCamera.transform.position = originalCameraPosition;
captureCamera.orthographicSize = originalCameraOrthographicSize;
if (captureCameraFollow != null)
captureCameraFollow.enabled = originalCameraFollowEnabled;
}
Time.timeScale = originalTimeScale;
}
[UnityTest]
public IEnumerator ColdTower_TiersFireSequentialSingleProjectilesWithPerShotImpacts()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
if (tower != cold) tower.enabled = false;
Assert.That(target.Enemy.TryReposition(cold.transform.position), Is.True);
Physics2D.SyncTransforms();
Sprite[] volleyFrames = DefenseTowerArt.Load("Effects/Ice/T1-Volley");
Sprite[] freezeboltFrames = DefenseTowerArt.Load("Effects/Ice/Freezebolt");
Assert.That(controller.Tuning.ColdProjectileBurstInterval, Is.EqualTo(0.12f));
yield return AssertColdVolley(cold, target, volleyFrames, 1,
controller.Tuning.ColdProjectileBurstInterval, faceLeft: true);
for (int tier = 2; tier <= 3; tier++)
{
Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.None), Is.True);
yield return AssertColdVolley(cold, target, volleyFrames, tier,
controller.Tuning.ColdProjectileBurstInterval, faceLeft: tier == 2);
}
Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.First), Is.True);
Assert.That(controller.Tuning.FreezeboltProjectileCount, Is.EqualTo(5));
yield return AssertColdVolley(cold, target, freezeboltFrames,
5, controller.Tuning.ColdProjectileBurstInterval, faceLeft: true);
}
[UnityTest]
public IEnumerator FireAndLightningProjectiles_StartAtStaffTipWhenFacingEitherDirection()
{
yield return StartTowerHitTestRun();
DefenseTower fire = FindTower(DefenseTowerKind.Direct);
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
if (tower != fire && tower != lightning) tower.enabled = false;
SetField(fire, "attackCooldown", 60f);
SetField(lightning, "attackCooldown", 60f);
yield return AssertStaffTipOriginsBothSides(fire, FindDummy(fire.Kind));
yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind));
Assert.That(player.TryReposition(
DefensePrototypeController.Instance.Tuning.GetBuildPadPosition(lightning.PadIndex)),
Is.True);
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.First), Is.True);
yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind));
}
[UnityTest]
public IEnumerator Blizzard_AutomaticallyHitsTheUnmovedColdDummy()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None))
tower.enabled = false;
SetField(cold, "attackCooldown", 60f);
yield return new WaitForSeconds(1f);
UpgradeToSecondBranch(controller, player, cold);
SetField(cold, "attackCooldown", -0.1f);
cold.enabled = true;
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
healthField.SetValue(target.Enemy, 10000f);
Vector2 originalRoot = target.transform.position;
Vector2 originalShadow = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, originalRoot);
List<(float damage, EnemyDamageSource source)> damageEvents = new();
System.Action<float, float> observer = (_, damage) =>
damageEvents.Add((damage, target.Enemy.LastDamageSource));
target.Enemy.OnDamageApplied += observer;
yield return new WaitForSeconds(0.9f);
float nearestEllipseDistance = float.PositiveInfinity;
for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++)
{
Vector2 landing = originalRoot
+ DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f);
Vector2 delta = originalShadow - landing;
float ellipseDistance = delta.x * delta.x
/ (DefenseTowerArtBounds.BlizzardWaveRadiusX
* DefenseTowerArtBounds.BlizzardWaveRadiusX)
+ delta.y * delta.y
/ (DefenseTowerArtBounds.BlizzardWaveRadiusY
* DefenseTowerArtBounds.BlizzardWaveRadiusY);
nearestEllipseDistance = Mathf.Min(nearestEllipseDistance, ellipseDistance);
}
Assert.That(damageEvents.Count, Is.GreaterThan(0),
$"Automatic Blizzard should hit its selected unmoved target. root={originalRoot}, "
+ $"shadow={originalShadow}, castCenter={originalRoot}, "
+ $"nearestEllipseDistance={nearestEllipseDistance:F4}.");
Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower));
Assert.That(Vector2.Distance(target.transform.position, originalRoot),
Is.LessThan(0.001f),
"The dummy remains at its original test-scene position.");
target.Enemy.OnDamageApplied -= observer;
}
[UnityTest]
public IEnumerator Blizzard_DamagesEachLandingEllipseAndHitsOverlapsSeparately()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None))
tower.enabled = false;
SetField(cold, "attackCooldown", 60f);
yield return new WaitForSeconds(1f);
UpgradeToSecondBranch(controller, player, cold);
SetField(cold, "attackCooldown", 60f);
cold.enabled = true;
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
healthField.SetValue(target.Enemy, 10000f);
Vector2 originalRoot = target.transform.position;
SpriteRenderer targetRenderer = target.Enemy.GetComponent<SpriteRenderer>();
targetRenderer.flipX = false;
Vector2 shadowOffset = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, originalRoot) - originalRoot;
List<(float time, float damage, EnemyDamageSource source)> damageEvents = new();
System.Action<float, float> damageObserver = (_, appliedDamage) =>
damageEvents.Add((Time.time, appliedDamage, target.Enemy.LastDamageSource));
target.Enemy.OnDamageApplied += damageObserver;
float expectedDamage = Mathf.Floor(controller.Tuning.ColdDamage
* (1f + controller.Tuning.TowerDamageBonusPerTier * (cold.Tier - 1)) * 0.5f);
Vector2 singleAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, target.transform.position);
Vector2 singleWaveGround = singleAttackCenter
+ DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
float singleStartHealth = target.Enemy.CurrentHealth;
float singleStartedAt = Time.time;
Assert.That(cold.TryPulse(), Is.True);
Vector2 singleRoot = singleWaveGround - shadowOffset;
Assert.That(target.Enemy.TryReposition(singleRoot), Is.True);
SetField(target, "stationaryTowerTestDummyAnchor", singleRoot);
Physics2D.SyncTransforms();
float singleHitDeadline = Time.realtimeSinceStartup + 0.8f;
while (damageEvents.Count == 0 && Time.realtimeSinceStartup < singleHitDeadline)
yield return null;
Assert.That(damageEvents.Count, Is.EqualTo(1),
"A target at one landing center is hit once by that wave only.");
Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower));
Assert.That(damageEvents[0].damage, Is.EqualTo(expectedDamage).Within(0.001f));
Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f));
Assert.That(target.Enemy.IsStunned, Is.True);
float singleRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f
- (Time.time - singleStartedAt);
if (singleRemaining > 0f) yield return new WaitForSeconds(singleRemaining);
Assert.That(damageEvents.Count, Is.EqualTo(1),
"The wave never repeats its hit while its expansion animation plays.");
Assert.That(singleStartHealth - target.Enemy.CurrentHealth,
Is.EqualTo(expectedDamage).Within(0.001f));
damageEvents.Clear();
healthField.SetValue(target.Enemy, 10000f);
SetField(cold, "attackCooldown", 60f);
Vector2 overlapAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, target.transform.position);
Vector2 first = DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
Vector2 second = DefenseTowerArtBounds.BlizzardLandingOffset(1, 1.65f);
Vector2 overlapGround = overlapAttackCenter + (first + second) * 0.5f;
float overlapStartedAt = Time.time;
Assert.That(cold.TryPulse(), Is.True);
Vector2 overlapRoot = overlapGround - shadowOffset;
Assert.That(target.Enemy.TryReposition(overlapRoot), Is.True);
SetField(target, "stationaryTowerTestDummyAnchor", overlapRoot);
Physics2D.SyncTransforms();
float overlapDeadline = Time.realtimeSinceStartup + 0.8f;
while (damageEvents.Count < 2 && Time.realtimeSinceStartup < overlapDeadline)
yield return null;
Assert.That(damageEvents.Count, Is.EqualTo(2),
"Only the two overlapping landing ellipses hit this target.");
for (int i = 0; i < damageEvents.Count; i++)
{
Assert.That(damageEvents[i].source, Is.EqualTo(EnemyDamageSource.Tower));
Assert.That(damageEvents[i].damage, Is.EqualTo(expectedDamage).Within(0.001f),
$"Wave event {i} applies the configured per-wave damage.");
}
Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f));
Assert.That(target.Enemy.IsStunned, Is.True);
float overlapRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f
- (Time.time - overlapStartedAt);
if (overlapRemaining > 0f) yield return new WaitForSeconds(overlapRemaining);
Assert.That(damageEvents.Count, Is.EqualTo(2),
"Neither overlapping wave repeats during its animated lifetime.");
Assert.That(10000f - target.Enemy.CurrentHealth,
Is.EqualTo(expectedDamage * 2f).Within(0.001f));
damageEvents.Clear();
healthField.SetValue(target.Enemy, 10000f);
SetField(cold, "attackCooldown", 60f);
Vector2 outerAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, target.transform.position);
Assert.That(cold.TryPulse(), Is.True);
Vector2 outerRoot = outerAttackCenter + new Vector2(1.5f, 0f) - shadowOffset;
Assert.That(target.Enemy.TryReposition(outerRoot), Is.True);
SetField(target, "stationaryTowerTestDummyAnchor", outerRoot);
Physics2D.SyncTransforms();
yield return new WaitForSeconds(DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f);
Assert.That(damageEvents.Count, Is.Zero,
"A target outside every wave ellipse but inside the cast radius is not hit.");
Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(10000f).Within(0.001f));
target.Enemy.OnDamageApplied -= damageObserver;
}
private static IEnumerator AssertStaffTipOriginsBothSides(
DefenseTower tower, DefenseRouteAgent target)
{
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
foreach (bool faceLeft in new[] { true, false })
{
healthField.SetValue(target.Enemy, 10000f);
Vector2 targetPosition = (Vector2)tower.transform.position
+ (faceLeft ? Vector2.left : Vector2.right) * 1.25f;
Assert.That(target.Enemy.TryReposition(targetPosition), Is.True);
Physics2D.SyncTransforms();
SetField(tower, "attackCooldown", 60f);
string effectName = tower.Kind == DefenseTowerKind.Direct
? "Direct Attack Effect" : "Lightning Attack Effect";
HashSet<int> previousEffectIds = new();
foreach (Transform existing in Object.FindObjectsByType<Transform>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
if (existing.name == effectName)
previousEffectIds.Add(existing.gameObject.GetInstanceID());
Assert.That(tower.TryPulse(), Is.True);
GameObject effect = null;
foreach (Transform candidate in Object.FindObjectsByType<Transform>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
{
if (candidate.name == effectName
&& !previousEffectIds.Contains(candidate.gameObject.GetInstanceID()))
{
effect = candidate.gameObject;
break;
}
}
Assert.That(effect, Is.Not.Null);
Transform shot = effect.transform.Find("Shot Sprite");
Assert.That(shot, Is.Not.Null);
SpriteRenderer renderer = shot.GetComponent<SpriteRenderer>();
Vector2 expectedStart = GetStaffTipWorldPosition(tower);
if (tower.Kind == DefenseTowerKind.Direct)
{
Vector2 travelStart = (Vector2)GetSequenceField(
shot.GetComponent<DefenseSpriteSequence>(), "travelStart");
Assert.That(Vector2.Distance(travelStart, expectedStart),
Is.LessThan(0.0001f),
$"Fire T{tower.Tier}, flipX={faceLeft}, travel start must match the attack-frame staff tip.");
Assert.That(Vector2.Distance(shot.position, expectedStart),
Is.LessThan(0.0001f), "Fireball renderer starts at the staff tip.");
}
else
{
string key = tower.Tier == 4
? "Effects/Lightning/Arc" : "Effects/Lightning/T1-Bolt";
int frame = FindFrameIndex(DefenseTowerArt.Load(key), renderer.sprite);
Vector2Int bounds = DefenseTowerArtBounds.LightningBeamXBounds(frame);
Vector3 localStart = new(
(bounds.x - renderer.sprite.pivot.x) / renderer.sprite.pixelsPerUnit,
0f, 0f);
Assert.That(Vector2.Distance(
renderer.transform.TransformPoint(localStart), expectedStart),
Is.LessThan(0.0001f),
$"Lightning T{tower.Tier} renderer starts at the staff tip.");
}
yield return null;
}
}
private static IEnumerator AssertColdVolley(
DefenseTower tower, DefenseRouteAgent target, Sprite[] expectedFrames,
int expectedCount, float burstInterval, bool faceLeft = false)
{
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(healthField, Is.Not.Null);
healthField.SetValue(target.Enemy, 10000f);
Vector2 targetPosition = (Vector2)tower.transform.position
+ (faceLeft ? Vector2.left : Vector2.right) * 1.25f;
Assert.That(target.Enemy.TryReposition(targetPosition), Is.True);
Physics2D.SyncTransforms();
float healthBefore = target.Enemy.CurrentHealth;
SetField(tower, "attackCooldown", 60f);
Assert.That(tower.TryPulse(), Is.True);
GameObject effect = GameObject.Find("Cold Attack Effect");
Assert.That(effect, Is.Not.Null);
SpriteRenderer[] renderers = effect.GetComponentsInChildren<SpriteRenderer>(true);
List<SpriteRenderer> shots = new();
foreach (SpriteRenderer renderer in renderers)
{
if (!renderer.name.StartsWith("Shot Sprite ")) continue;
shots.Add(renderer);
Assert.That(renderer.sprite, Is.SameAs(expectedFrames[0]),
"Every projectile must reuse one existing projectile sheet.");
}
Assert.That(shots.Count, Is.EqualTo(expectedCount));
AssertStaffTipStart(tower, shots[0]);
for (int i = 0; i < shots.Count; i++)
Assert.That(shots[i].enabled, Is.EqualTo(i == 0),
"Only the first projectile is visible at launch.");
HashSet<int> impactIds = new();
HashSet<int> hiddenShotsAtImpact = new();
Dictionary<int, float> launchTimes = new();
bool flightRendererHiddenAtImpact = false;
float previousHealth = healthBefore;
int damageEvents = 0;
float deadline = Time.realtimeSinceStartup + 1.5f;
while (Time.realtimeSinceStartup < deadline && effect != null)
{
float now = Time.realtimeSinceStartup;
for (int i = 0; i < shots.Count; i++)
if (shots[i] != null && shots[i].enabled && !launchTimes.ContainsKey(i))
{
launchTimes.Add(i, now);
AssertStaffTipStart(tower, shots[i]);
}
foreach (Transform child in effect.transform)
if (child.name == "Impact Sprite")
impactIds.Add(child.gameObject.GetInstanceID());
if (impactIds.Count > 0 && (shots[0] == null || !shots[0].enabled))
flightRendererHiddenAtImpact = true;
for (int i = 0; i < shots.Count; i++)
if (impactIds.Count > i && (shots[i] == null || !shots[i].enabled))
hiddenShotsAtImpact.Add(i);
float currentHealth = target.Enemy.CurrentHealth;
if (currentHealth < previousHealth)
{
damageEvents++;
previousHealth = currentHealth;
}
yield return null;
}
Assert.That(launchTimes.Count, Is.EqualTo(expectedCount),
"Each projectile renderer must become visible in sequence.");
for (int i = 1; i < expectedCount; i++)
Assert.That(launchTimes[i] - launchTimes[i - 1], Is.EqualTo(burstInterval).Within(0.035f));
Assert.That(impactIds.Count, Is.EqualTo(expectedCount),
"Each projectile must create a distinct impact before shared cleanup.");
Assert.That(flightRendererHiddenAtImpact, Is.True,
"The flight renderer must be hidden on arrival while its impact still animates.");
Assert.That(hiddenShotsAtImpact.Count, Is.EqualTo(expectedCount),
"Every flight renderer must be hidden on its own arrival.");
Assert.That(damageEvents, Is.EqualTo(expectedCount),
"Every projectile must apply its existing damage on arrival.");
Assert.That(target.Enemy.CurrentHealth, Is.LessThan(healthBefore));
}
private static void AssertStaffTipStart(DefenseTower tower, SpriteRenderer shot)
{
Vector2 travelStart = (Vector2)GetSequenceField(
shot.GetComponent<DefenseSpriteSequence>(), "travelStart");
Assert.That(IsStaffTipAnchor(tower, travelStart), Is.True,
"Each Cold projectile, including delayed shots, starts at a measured staff-tip frame anchor.");
}
private static bool IsStaffTipAnchor(DefenseTower tower, Vector2 position)
{
SpriteRenderer body = (SpriteRenderer)typeof(DefenseTower).GetField(
"bodyRenderer", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(tower);
Sprite sprite = body.sprite;
for (int frame = 0; frame < 6; frame++)
{
Vector2 tipPixels = DefenseTowerArtBounds.StaffTipPixels(true, frame);
Vector2 localPixels = tipPixels - sprite.pivot;
localPixels.y = sprite.rect.height - tipPixels.y - sprite.pivot.y;
if (body.flipX) localPixels.x = -localPixels.x;
Vector2 anchor = body.transform.TransformPoint(
localPixels / sprite.pixelsPerUnit);
if (Vector2.Distance(anchor, position) < 0.0001f) return true;
}
return false;
}
private static Vector2 GetStaffTipWorldPosition(DefenseTower tower) =>
(Vector2)typeof(DefenseTower).GetMethod("GetStaffTipWorldPosition",
BindingFlags.Instance | BindingFlags.NonPublic).Invoke(tower, null);
private static object GetSequenceField(DefenseSpriteSequence sequence, string name) =>
typeof(DefenseSpriteSequence).GetField(name,
BindingFlags.Instance | BindingFlags.NonPublic).GetValue(sequence);
[UnityTest]
public IEnumerator LightningTiersOneToThreeDoNotCreateImpactWaves()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Lightning);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
if (tower != lightning) tower.enabled = false;
Assert.That(target.Enemy.TryReposition(lightning.transform.position), Is.True);
Physics2D.SyncTransforms();
for (int tier = 1; tier <= 3; tier++)
{
if (tier > 1)
{
Assert.That(player.TryReposition(
controller.Tuning.GetBuildPadPosition(lightning.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
}
SetField(lightning, "attackCooldown", 60f);
Assert.That(lightning.TryPulse(), Is.True, $"T{tier} lightning should attack.");
GameObject effect = GameObject.Find("Lightning Attack Effect");
Assert.That(effect, Is.Not.Null);
foreach (SpriteRenderer renderer in effect.GetComponentsInChildren<SpriteRenderer>(true))
Assert.That(renderer.name, Is.Not.EqualTo("Impact Sprite"),
$"T{tier} lightning must not create an impact wave.");
Object.DestroyImmediate(effect);
}
}
[UnityTest]
public IEnumerator TowerVisuals_AlignHitsFitStormShrinkStunAndStaggerBlizzardLanding()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
Assert.That(controller, Is.Not.Null);
Assert.That(player, Is.Not.Null);
DefenseTower[] allTowers = Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None);
foreach (DefenseTower tower in allTowers) tower.enabled = false;
// Fire impact uses the enemy body position and target-relative sorting.
DefenseTower fire = FindTower(DefenseTowerKind.Direct);
DefenseRouteAgent fireTarget = FindDummy(DefenseTowerKind.Direct);
fire.enabled = true;
SetField(fire, "attackCooldown", 60f);
float fireHealth = fireTarget.Enemy.CurrentHealth;
Assert.That(fire.TryPulse(), Is.True);
float fireDeadline = Time.realtimeSinceStartup + 1f;
while (Time.realtimeSinceStartup < fireDeadline
&& fireTarget.Enemy.CurrentHealth >= fireHealth)
yield return null;
Assert.That(fireTarget.Enemy.CurrentHealth, Is.LessThan(fireHealth));
GameObject fireEffect = GameObject.Find("Direct Attack Effect");
Assert.That(fireEffect, Is.Not.Null);
Transform fireImpact = fireEffect.transform.Find("Impact Sprite");
Assert.That(fireImpact, Is.Not.Null);
SpriteRenderer fireRenderer = fireImpact.GetComponent<SpriteRenderer>();
SpriteRenderer fireTargetRenderer = fireTarget.Enemy.GetComponent<SpriteRenderer>();
Vector2 expectedFireCenter = fireTarget.Enemy.transform.position;
Assert.That(Vector2.Distance(fireImpact.position, expectedFireCenter),
Is.LessThan(0.01f));
AssertHitSorting(fireRenderer, fireTargetRenderer);
GameObject sharedFireImpact = GameObject.Find("Tower Impact Direct");
Assert.That(sharedFireImpact, Is.Not.Null);
AssertHitSorting(sharedFireImpact.GetComponent<SpriteRenderer>(), fireTargetRenderer);
CaptureRender("tower-visual-01-fire-impact.png");
// LightningStorm's measured ground center and furthest ring pixel fit radius 2.4.
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
UpgradeToSecondBranch(controller, player, lightning);
lightning.enabled = true;
SetField(lightning, "attackCooldown", 60f);
Assert.That(DefenseTower.GetBranchName(
DefenseTowerKind.Lightning, DefenseTowerBranch.Second),
Is.EqualTo("LIGHTNING STORM"));
Assert.That(lightning.TryPulse(), Is.True);
CaptureRender("tower-visual-02-lightning-storm.png");
GameObject lightningEffect = GameObject.Find("Lightning Attack Effect");
Assert.That(lightningEffect, Is.Not.Null);
Transform storm = lightningEffect.transform.Find("Shot Sprite");
Assert.That(storm, Is.Not.Null);
DefenseSpriteSequence stormSequence =
storm.GetComponent<DefenseSpriteSequence>();
SpriteRenderer stormRenderer = storm.GetComponent<SpriteRenderer>();
Vector2 stormCenter = FindDummy(DefenseTowerKind.Lightning).transform.position;
AssertHitSorting(stormRenderer,
FindDummy(DefenseTowerKind.Lightning).Enemy.GetComponent<SpriteRenderer>());
const string stormKey = "Effects/Lightning/LightningStorm";
Sprite[] stormSprites = DefenseTowerArt.Load(stormKey);
Assert.That(stormSprites, Has.Length.EqualTo(16));
GameObject boundsProbe = new("Lightning Storm Bounds Probe");
SpriteRenderer probeRenderer = boundsProbe.AddComponent<SpriteRenderer>();
DefenseSpriteSequence probeSequence =
boundsProbe.AddComponent<DefenseSpriteSequence>();
probeSequence.Play(probeRenderer, stormKey, stormSprites, false);
for (int i = 0; i < stormSprites.Length; i++)
{
Sprite frame = stormSprites[i];
probeRenderer.sprite = frame;
probeSequence.FitLightningStormGround(stormCenter, 2.4f);
Vector2 localCenter =
(DefenseTowerArtBounds.LightningStormGroundCenterPixels - frame.pivot)
/ frame.pixelsPerUnit;
Assert.That(Vector2.Distance(
probeRenderer.transform.TransformPoint(localCenter), stormCenter),
Is.LessThan(0.01f), $"Ground center frame {i}.");
float fittedRadius = probeRenderer.transform.lossyScale.x
* DefenseTowerArtBounds.LightningStormGroundRadiusPixels
/ frame.pixelsPerUnit;
Assert.That(fittedRadius, Is.LessThanOrEqualTo(2.4001f),
$"Measured ground footprint frame {i}.");
}
Object.Destroy(boundsProbe);
float ringFrameDeadline = Time.realtimeSinceStartup + 1f;
int visibleStormFrame = FindFrameIndex(stormSprites, stormRenderer.sprite);
while ((visibleStormFrame < 4 || visibleStormFrame > 10)
&& Time.realtimeSinceStartup < ringFrameDeadline)
{
yield return null;
visibleStormFrame = stormRenderer != null
? FindFrameIndex(stormSprites, stormRenderer.sprite) : -1;
}
Assert.That(visibleStormFrame, Is.InRange(4, 10),
"The second Storm capture must include visible ground-ring frames.");
CaptureRender("tower-visual-02b-lightning-groundring.png");
// Seismic shows a small shadow-centered marker while keeping tower range guidance.
DefenseTower seismic = FindTower(DefenseTowerKind.Seismic);
DefenseRouteAgent seismicTarget = FindDummy(DefenseTowerKind.Seismic);
seismic.enabled = true;
SetField(seismic, "attackCooldown", 60f);
Assert.That(seismic.TryPulse(), Is.True);
GameObject seismicEffect = GameObject.Find("Seismic Attack Effect");
Assert.That(seismicEffect, Is.Not.Null);
SpriteRenderer stunArt = seismicEffect.transform.Find("Impact Sprite")
.GetComponent<SpriteRenderer>();
SpriteRenderer seismicTargetRenderer = seismicTarget.Enemy.GetComponent<SpriteRenderer>();
const int seismicMaximumVisiblePixels = 32;
float expectedMarkerScale = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels
* Mathf.Abs(seismicTargetRenderer.transform.lossyScale.x)
* stunArt.sprite.pixelsPerUnit
/ (seismicTargetRenderer.sprite.pixelsPerUnit
* seismicMaximumVisiblePixels
* Mathf.Abs(seismicEffect.transform.lossyScale.x));
Assert.That(stunArt.transform.localScale.x,
Is.EqualTo(expectedMarkerScale).Within(0.01f),
"The constant effect scale must fit the measured maximum visible frame width.");
Assert.That((Vector2)stunArt.transform.position,
Is.EqualTo(DefenseTowerArtBounds.EnemyShadowWorldPosition(
seismicTarget.Enemy, seismicTarget.transform.position)));
Assert.That(stunArt.sortingLayerID, Is.EqualTo(seismicTargetRenderer.sortingLayerID));
Assert.That(stunArt.sortingOrder, Is.EqualTo(seismicTargetRenderer.sortingOrder - 1));
Assert.That(seismicEffect.transform.Find("Impact Radius"), Is.Null,
"The large transient Seismic radius ring is omitted from the ground marker.");
LineRenderer seismicRange = seismic.transform.Find("Effect Range")
.GetComponent<LineRenderer>();
Assert.That(seismicRange, Is.Not.Null,
"The tower's persistent attack-range display remains available.");
Assert.That(seismicRange.GetPosition(0).magnitude, Is.GreaterThan(0f));
CaptureRender("tower-visual-03-seismic-stun.png");
// Blizzard staggers shards and applies damage at each landing ellipse.
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
SetField(cold, "attackCooldown", 60f);
cold.enabled = false;
yield return new WaitForSeconds(1f);
UpgradeToSecondBranch(controller, player, cold);
cold.enabled = true;
SetField(cold, "attackCooldown", 60f);
DefenseRouteAgent coldTarget = FindDummy(DefenseTowerKind.Cold);
float coldHealth = coldTarget.Enemy.CurrentHealth;
float blizzardStartedAt = Time.time;
Assert.That(DefenseTower.GetBranchName(
DefenseTowerKind.Cold, DefenseTowerBranch.Second),
Is.EqualTo("BLIZZARD"));
Assert.That(cold.TryPulse(), Is.True);
GameObject coldEffect = GameObject.Find("Cold Attack Effect");
Assert.That(coldEffect, Is.Not.Null);
Vector2 coldShadowCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
coldTarget.Enemy, coldTarget.transform.position);
Assert.That(Vector2.Distance(coldEffect.transform.position,
coldShadowCenter), Is.LessThan(0.01f));
LineRenderer radiusLine = coldEffect.transform.Find("Impact Radius")
.GetComponent<LineRenderer>();
Assert.That(radiusLine, Is.Not.Null);
Assert.That(radiusLine.startColor.a,
Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactRadiusAlpha).Within(0.001f),
"Only Blizzard softens the per-cast radius outline.");
Assert.That(cold.transform.Find("Effect Range").GetComponent<LineRenderer>()
.startColor.a, Is.EqualTo(DefenseTowerArtBounds.BlizzardRangeAlpha).Within(0.001f),
"Only Blizzard uses the quieter live range outline.");
float radius = 0f;
for (int i = 0; i < radiusLine.positionCount; i++)
radius = Mathf.Max(radius, radiusLine.GetPosition(i).magnitude);
Assert.That(radius, Is.EqualTo(1.65f).Within(0.01f));
Assert.That(coldTarget.Enemy.CurrentHealth, Is.EqualTo(coldHealth),
"Blizzard damage waits for the first shard landing.");
SpriteRenderer[] blizzardSprites =
coldEffect.GetComponentsInChildren<SpriteRenderer>(true);
SpriteRenderer[] shards = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
SpriteRenderer[] trails = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
SpriteRenderer[] impacts = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
SpriteRenderer[] waves = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
int shardCount = 0;
int trailCount = 0;
int impactCount = 0;
int waveCount = 0;
foreach (SpriteRenderer renderer in blizzardSprites)
{
if (renderer.name.StartsWith("Blizzard Shard "))
{
int index = renderer.name["Blizzard Shard ".Length] - '0';
shards[index] = renderer;
shardCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardShardScale).Within(0.001f));
Assert.That(renderer.sprite,
Is.SameAs(DefenseTowerArt.Load("Effects/Ice/Shard-Blizzard-v1")[0]));
Assert.That(renderer.enabled, Is.EqualTo(index == 0),
"Delayed shards stay hidden until their staggered launch.");
AssertHitSorting(renderer,
coldTarget.Enemy.GetComponent<SpriteRenderer>());
}
else if (renderer.name.StartsWith("Blizzard Trail "))
{
int index = renderer.name["Blizzard Trail ".Length] - '0';
trails[index] = renderer;
trailCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailScale).Within(0.001f));
Assert.That(renderer.color.a,
Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailAlpha).Within(0.001f));
Assert.That(renderer.enabled, Is.EqualTo(index == 0),
"The brief shard trail follows its staggered projectile.");
}
else if (renderer.name.StartsWith("Blizzard Impact "))
{
int index = renderer.name["Blizzard Impact ".Length] - '0';
impacts[index] = renderer;
impactCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactScale).Within(0.001f));
Assert.That(renderer.enabled, Is.False,
"Each impact stays hidden until its shard lands.");
AssertHitSorting(renderer,
coldTarget.Enemy.GetComponent<SpriteRenderer>());
}
else if (renderer.name.StartsWith("Blizzard Wave "))
{
int index = renderer.name["Blizzard Wave ".Length] - '0';
waves[index] = renderer;
waveCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardWaveScale).Within(0.001f));
Assert.That(renderer.enabled, Is.False,
"Each ground wave waits for its matching shard landing.");
Vector2 expectedCenter = (Vector2)coldEffect.transform.position
+ DefenseTowerArtBounds.BlizzardLandingOffset(index, radius);
Vector2 localCenter = (DefenseTowerArtBounds.ImpactWaveCenterPixels(
0, renderer.sprite) - renderer.sprite.pivot)
/ renderer.sprite.pixelsPerUnit;
Assert.That(Vector2.Distance(
renderer.transform.TransformPoint(localCenter), expectedCenter),
Is.LessThan(0.01f), $"Wave {index} stays centered on its landing.");
float outerRadius = 0f;
foreach (Vector2 vertex in renderer.sprite.vertices)
{
outerRadius = Mathf.Max(outerRadius, Vector2.Distance(
renderer.transform.TransformPoint(vertex), expectedCenter));
}
Assert.That(Vector2.Distance(expectedCenter, coldEffect.transform.position)
+ outerRadius, Is.LessThanOrEqualTo(radius + 0.01f),
$"Wave {index} stays within the Blizzard attack radius.");
AssertHitSorting(renderer,
coldTarget.Enemy.GetComponent<SpriteRenderer>());
}
}
Assert.That(shardCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
Assert.That(trailCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
Assert.That(impactCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
Assert.That(waveCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
float firstLandingDeadline = Time.realtimeSinceStartup + 0.6f;
while (!impacts[0].enabled && Time.realtimeSinceStartup < firstLandingDeadline)
yield return null;
Assert.That(impacts[0].enabled, Is.True,
"The first impact appears when the first shard lands.");
Assert.That(waves[0].enabled, Is.True,
"The first ground wave appears when the first shard lands.");
Assert.That(Time.time - blizzardStartedAt,
Is.EqualTo(DefenseTowerArtBounds.BlizzardLandingSeconds).Within(0.05f));
yield return new WaitForSeconds(0.21f);
float landingDeadline = Time.realtimeSinceStartup + 0.25f;
while (Time.realtimeSinceStartup < landingDeadline
&& !impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled)
yield return null;
Assert.That(impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled, Is.True,
"The final wave lands at its staggered time.");
Assert.That(Time.time - blizzardStartedAt,
Is.EqualTo(DefenseTowerArtBounds.BlizzardLastLandingSeconds).Within(0.05f));
int lastImpact = DefenseTowerArtBounds.BlizzardShardCount - 1;
yield return null;
Assert.That(impacts[lastImpact].enabled, Is.True,
"The final impact appears on the frame after its shard lands.");
Assert.That(waves[lastImpact].enabled, Is.True,
"The final wave appears on the frame after its shard lands.");
float captureTimeScale = Time.timeScale;
Time.timeScale = 0f;
CaptureRender("tower-visual-04-blizzard-last-landing.png");
Time.timeScale = captureTimeScale;
yield return new WaitForSeconds(0.2f);
Assert.That(coldEffect, Is.Not.Null,
"The attack root remains alive while the final wave animation is playing.");
float cleanupDeadline = Time.realtimeSinceStartup + 0.6f;
while (coldEffect != null && Time.realtimeSinceStartup < cleanupDeadline)
yield return null;
Assert.That(coldEffect == null, Is.True,
"The attack root is destroyed after every shard, impact, trail, and wave sequence finishes.");
}
private void CaptureRender(string fileName)
{
if (!cameraStateSaved)
{
captureCameraFollow = Object.FindAnyObjectByType<ArenaCameraFollow>();
captureCamera = captureCameraFollow != null
? captureCameraFollow.WorldCamera : Camera.main;
Assert.That(captureCamera, Is.Not.Null);
originalCameraPosition = captureCamera.transform.position;
originalCameraOrthographicSize = captureCamera.orthographicSize;
originalCameraFollowEnabled = captureCameraFollow != null
&& captureCameraFollow.enabled;
cameraStateSaved = true;
}
if (captureCameraFollow != null) captureCameraFollow.enabled = false;
captureCamera.transform.position = new Vector3(
0f, 0f, captureCamera.transform.position.z);
captureCamera.orthographicSize = 9f;
const int width = 1920;
const int height = 1080;
RenderTexture renderTarget = new(width, height, 24);
Assert.That(renderTarget.Create(), Is.True);
Texture2D image = new(width, height, TextureFormat.RGB24, false);
RenderTexture previousTarget = captureCamera.targetTexture;
RenderTexture previousActive = RenderTexture.active;
try
{
captureCamera.targetTexture = renderTarget;
captureCamera.Render();
RenderTexture.active = renderTarget;
image.ReadPixels(new Rect(0, 0, width, height), 0, 0);
image.Apply();
Directory.CreateDirectory(
@"D:\project\game\output\tower-hit-test-v1");
File.WriteAllBytes(
Path.Combine(@"D:\project\game\output\tower-hit-test-v1", fileName),
image.EncodeToPNG());
}
finally
{
captureCamera.targetTexture = previousTarget;
RenderTexture.active = previousActive;
Object.Destroy(image);
renderTarget.Release();
Object.Destroy(renderTarget);
}
}
private static int FindFrameIndex(Sprite[] frames, Sprite current)
{
for (int i = 0; i < frames.Length; i++)
if (frames[i] == current) return i;
return -1;
}
private static void AssertHitSorting(Renderer effect, SpriteRenderer target)
{
Assert.That(effect.sortingLayerID, Is.EqualTo(target.sortingLayerID));
Assert.That(effect.sortingOrder,
Is.EqualTo(target.sortingOrder + DefenseTowerArtBounds.TowerHitSortingOffset));
}
private static void UpgradeToSecondBranch(
DefensePrototypeController controller, PlayerController player,
DefenseTower tower)
{
Assert.That(player.TryReposition(
controller.Tuning.GetBuildPadPosition(tower.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
DefenseTowerBranch.None), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
DefenseTowerBranch.None), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
DefenseTowerBranch.Second), Is.True);
}
private static DefenseTower FindTower(DefenseTowerKind kind)
{
int padIndex = System.Array.IndexOf(
DefenseTowerHitTestLayout.KindsByPad, kind);
Assert.That(padIndex, Is.GreaterThanOrEqualTo(0),
$"Tower kind {kind} is absent from the hit-test layout.");
DefenseTower[] towers = Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None);
foreach (DefenseTower tower in towers)
if (tower.PadIndex == padIndex && tower.Kind == kind) return tower;
Assert.Fail($"Missing tower {kind} at pad {padIndex}.");
return null;
}
private static DefenseRouteAgent FindDummy(DefenseTowerKind kind)
{
DefenseRouteAgent[] agents = Object.FindObjectsByType<DefenseRouteAgent>(
FindObjectsSortMode.None);
string expectedName = $"Tower Test Dummy {kind}";
foreach (DefenseRouteAgent agent in agents)
if (agent.Enemy != null && agent.Enemy.name == expectedName)
return agent;
Assert.Fail($"Missing fixed target {expectedName}.");
return null;
}
private static void SetField(object instance, string name, object value)
{
FieldInfo field = instance.GetType().GetField(
name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null, $"Missing field {name}.");
field.SetValue(instance, value);
}
private static IEnumerator StartTowerHitTestRun()
{
AsyncOperation load = SceneManager.LoadSceneAsync(SceneName);
while (!load.isDone) yield return null;
yield return null;
yield return null;
RunManager runManager = RunManager.Instance;
Assert.That(runManager, Is.Not.Null);
DefensePrototypeController.PrepareTowerHitTestRun();
Assert.That(runManager.BeginRun(RunMode.DefensePrototype), Is.True);
float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
while (Time.realtimeSinceStartup < deadline
&& (DefensePrototypeController.Instance == null
|| !DefensePrototypeController.Instance.IsActive))
yield return null;
Assert.That(DefensePrototypeController.Instance, Is.Not.Null);
Assert.That(DefensePrototypeController.Instance.IsActive, Is.True);
yield return CloseStartupSelection(runManager);
}
private static IEnumerator CloseStartupSelection(RunManager runManager)
{
float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
while (Time.realtimeSinceStartup < deadline && !runManager.IsSelectionOpen)
yield return null;
Assert.That(runManager.IsSelectionOpen, Is.True);
#if UNITY_EDITOR
ArtifactRewardController rewardController =
Object.FindAnyObjectByType<ArtifactRewardController>();
Assert.That(rewardController, Is.Not.Null);
int count = runManager.StartupArtifactSelectionCount;
Assert.That(count, Is.GreaterThan(0));
for (int i = 0; i < count; i++)
{
Assert.That(rewardController.DebugChooseOption(0), Is.True);
yield return null;
}
#else
Assert.Ignore("The tower visual fixture requires Unity Editor test APIs.");
#endif
yield return null;
#if UNITY_EDITOR
RunTutorialController tutorial =
Object.FindAnyObjectByType<RunTutorialController>();
Assert.That(tutorial, Is.Not.Null);
bool observed = false;
deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
while (Time.realtimeSinceStartup < deadline)
{
if (tutorial.DebugIsVisible)
{
observed = true;
Assert.That(tutorial.DebugContinue(), Is.True);
yield return null;
continue;
}
if (!runManager.IsSelectionOpen && tutorial.DebugPendingCount == 0)
break;
yield return null;
}
Assert.That(observed, Is.True);
Assert.That(tutorial.DebugIsVisible, Is.False);
#else
Assert.Ignore("The tower visual fixture requires Unity Editor test APIs.");
#endif
Assert.That(runManager.IsSelectionOpen, Is.False);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bf51fb447b414b2da8684437f1bded7d