335 lines
18 KiB
C#
335 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|