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(); 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(); 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(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(); 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(); 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(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; } } }