2.5d test

This commit is contained in:
2026-09-27 21:08:05 +09:00
parent 84dd12414a
commit abd2da2057
201 changed files with 24297 additions and 295 deletions
@@ -0,0 +1,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;
}
}
}