330 lines
20 KiB
C#
330 lines
20 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|