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(); 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(); 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(); 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(); Renderer wallRenderer = wall.GetComponent(); 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(); probeRenderer.sharedMaterial = world.Settings.PathMaterial; Object.DestroyImmediate(depthProbe.GetComponent()); 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(); 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 northWall = GameObject.Find("North boundary").GetComponent(); BoxCollider eastWall = GameObject.Find("East boundary").GetComponent(); BoxCollider westWall = GameObject.Find("West boundary").GetComponent(); Bounds groundBounds = GameObject.Find("XZ ground").GetComponent().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(); 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; } } }