Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs
T
2026-09-27 21:08:05 +09:00

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