2.5d test
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user