2.5 update
This commit is contained in:
@@ -522,6 +522,50 @@ namespace BumpCombat.Tests
|
||||
Assert.That(world.Gauge, Is.EqualTo(gauge).Within(.02f), "Holding into a wall must not charge movement gauge.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DenseEnemyActorsDoNotHideWallWhenMovementCastBufferFills()
|
||||
{
|
||||
const int blockerActorCount = 80;
|
||||
world.Player.ResetAt(Vector2.zero);
|
||||
for (int i = 0; i < blockerActorCount; i++)
|
||||
{
|
||||
GameObject actorObject = new($"Dense movement actor {i}");
|
||||
actorObject.transform.SetParent(world.transform, false);
|
||||
actorObject.transform.position = Defense25DActor.OnGround(
|
||||
new Vector2(.55f + i * .0005f, 0f), settings.GroundY);
|
||||
actorObject.AddComponent<Defense25DActor>().Configure(world, ComparisonActorKind.Enemy);
|
||||
}
|
||||
GameObject wall = Wall(new Vector2(1.2f, 0f), new Vector3(.2f, 1.5f, 6f));
|
||||
Collider wallCollider = wall.GetComponent<Collider>();
|
||||
Physics.SyncTransforms();
|
||||
|
||||
CapsuleCollider capsule = world.Player.BodyCollider;
|
||||
float cylinder = Mathf.Max(0f, capsule.height * .5f - capsule.radius);
|
||||
Vector3 center = Defense25DActor.OnGround(world.Player.Center,
|
||||
settings.GroundY + capsule.center.y);
|
||||
RaycastHit[] initialCapacity = new RaycastHit[64];
|
||||
int initialHitCount = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
|
||||
center - Vector3.up * cylinder, capsule.radius, Vector3.right,
|
||||
initialCapacity, 2f + settings.MovementSkin,
|
||||
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
|
||||
QueryTriggerInteraction.Ignore);
|
||||
Assert.That(initialHitCount, Is.EqualTo(initialCapacity.Length),
|
||||
"The fixture must saturate the original movement hit capacity with real actor colliders.");
|
||||
Assert.That(initialCapacity.Count(hit => hit.collider != wallCollider &&
|
||||
hit.collider.name.StartsWith("Dense movement actor", StringComparison.Ordinal)),
|
||||
Is.GreaterThanOrEqualTo(60),
|
||||
"Real actor colliders must occupy nearly all of the original movement hit capacity.");
|
||||
|
||||
world.Player.Move(Vector2.right * 2f);
|
||||
Assert.That(world.Player.MovementBlocked, Is.True,
|
||||
"Dense pass-through actors must not hide the actual wall from the expanded movement query.");
|
||||
Assert.That(world.Player.LastMovementBlocker, Is.EqualTo(wallCollider));
|
||||
Assert.That(world.Player.LastMove.x, Is.GreaterThan(.5f).And.LessThan(1f),
|
||||
"The player must advance through pass-through enemies, then stop before the wall.");
|
||||
yield return new WaitForFixedUpdate();
|
||||
Assert.That(world.Player.Position.x, Is.LessThan(1.2f - .1f + settings.MovementSkin));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator BumpDeduplicatesColliderHitsAndHonorsTargetInterval()
|
||||
{
|
||||
@@ -1062,6 +1106,181 @@ namespace BumpCombat.Tests
|
||||
Assert.That(CountCyclonePixels(exposed), Is.GreaterThan(CountCyclonePixels(occluded)),
|
||||
"Removing the wall must reveal effect pixels previously hidden by real depth.");
|
||||
wallRenderer.enabled = true;
|
||||
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CaptureCycloneFrontBackAndOrbitAtEightFramesForNormalAndCharged()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
Object.Destroy(world.gameObject);
|
||||
yield return null;
|
||||
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(
|
||||
"Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
||||
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
||||
yield return null; // Saved scene initialization runs in Start.
|
||||
Assert.That(world, Is.Not.Null);
|
||||
world.ReadInput = false;
|
||||
world.AutoSpawn = false;
|
||||
world.ShowBoundaries = false;
|
||||
world.ResetRun();
|
||||
CompleteStartupArtifactSelection(world);
|
||||
world.SetPaused(false);
|
||||
world.DebugFillGauge();
|
||||
Assert.That(world.View, Is.Not.Null);
|
||||
Camera sourceCamera = world.View.Camera;
|
||||
Vector3 originalPosition = sourceCamera.transform.position;
|
||||
Quaternion originalRotation = sourceCamera.transform.rotation;
|
||||
float originalSize = sourceCamera.orthographicSize;
|
||||
System.Reflection.FieldInfo elapsedField = typeof(Defense25DComparison).GetField(
|
||||
"<CycloneElapsed>k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.That(elapsedField, Is.Not.Null, "Use paused exact elapsed values for synchronized frame captures.");
|
||||
System.Reflection.FieldInfo orbitRendererField = typeof(Defense25DCycloneVisual).GetField(
|
||||
"orbitRenderer", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.That(orbitRendererField, Is.Not.Null);
|
||||
MeshRenderer orbitRenderer = (MeshRenderer)orbitRendererField.GetValue(world.CycloneVisual);
|
||||
SpriteRenderer bodyRenderer = world.Player.Visual.Renderer;
|
||||
Shader maskShader = Resources.Load<Shader>(
|
||||
"Artifacts/ThreeColor-v1/CycloneOrbit25D-v1/CycloneBodyMask");
|
||||
Assert.That(maskShader, Is.Not.Null);
|
||||
Material orbitMaterial = orbitRenderer.sharedMaterial;
|
||||
Assert.That(orbitMaterial.shader, Is.EqualTo(maskShader));
|
||||
Assert.That(orbitMaterial.HasProperty("_BodyMaskEnabled"), Is.True);
|
||||
|
||||
try
|
||||
{
|
||||
sourceCamera.orthographicSize = 2f;
|
||||
foreach (bool charged in new[] { false, true })
|
||||
{
|
||||
world.CancelCyclone();
|
||||
world.SetPaused(false);
|
||||
world.DebugFillGauge();
|
||||
Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True, "Each visual comparison needs a live Cyclone.");
|
||||
world.SetPaused(true);
|
||||
Vector2 lookAt = (world.Player.Position + world.CycloneCenter) * .5f;
|
||||
Vector3 target = Defense25DActor.OnGround(lookAt, world.Settings.GroundY + .45f);
|
||||
sourceCamera.transform.position = target - sourceCamera.transform.forward * 10f;
|
||||
world.CycloneVisual.Height = .15f;
|
||||
world.CycloneVisual.RadiusScale = 1f;
|
||||
world.CycloneVisual.DecorationSpeed = 1f;
|
||||
for (int frame = 0; frame < 8; frame++)
|
||||
{
|
||||
elapsedField.SetValue(world, frame * .05f);
|
||||
for (int i = 0; i < 2; i++) yield return null;
|
||||
string prefix = charged ? "charged" : "normal";
|
||||
world.CycloneVisual.Ribbon = false;
|
||||
yield return null;
|
||||
Color32[] referencePixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-front-back-frame-{frame:00}");
|
||||
world.CycloneVisual.Ribbon = true;
|
||||
yield return null;
|
||||
Color32[] orbitPixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-orbit-frame-{frame:00}");
|
||||
Assert.That(CountCyclonePixels(orbitPixels), Is.GreaterThan(100), "Orbit curves must contribute visible mint pixels.");
|
||||
Assert.That(DifferentPixels(referencePixels, orbitPixels), Is.GreaterThan(100), "Both captures must reflect their selected renderer.");
|
||||
}
|
||||
int restoredBodyPixels = 0;
|
||||
var playFrame = typeof(Defense25DSprite).GetMethod("StartTimedSequence",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.That(playFrame, Is.Not.Null);
|
||||
foreach (bool walking in new[] { false, true })
|
||||
foreach (bool flip in new[] { false, true })
|
||||
{
|
||||
Sprite[] motion = walking ? world.Settings.PlayerArt.Walk : world.Settings.PlayerArt.Idle;
|
||||
Assert.That(motion.Length, Is.GreaterThan(0));
|
||||
playFrame.Invoke(world.Player.Visual, new object[] {
|
||||
new[] { motion[walking ? Mathf.Min(1, motion.Length - 1) : 0] }, 1f, walking ? "Walk" : "Idle" });
|
||||
world.Player.Facing = flip ? Vector2.left : Vector2.right;
|
||||
elapsedField.SetValue(world, .1f);
|
||||
string label = $"mask-{(charged ? "charged" : "normal")}-{(walking ? "walk" : "idle")}-{(flip ? "left" : "right")}";
|
||||
world.CycloneVisual.ProtectBody = false;
|
||||
yield return null;
|
||||
yield return null;
|
||||
Color32[] unmasked = CaptureCamera(sourceCamera, label + "-off");
|
||||
world.CycloneVisual.ProtectBody = true;
|
||||
yield return null;
|
||||
Color32[] masked = CaptureCamera(sourceCamera, label + "-on");
|
||||
world.CycloneVisual.SetVisible(false);
|
||||
yield return null;
|
||||
Color32[] baseline = CaptureCamera(sourceCamera, label + "-hidden");
|
||||
world.CycloneVisual.SetVisible(true);
|
||||
Assert.That(bodyRenderer.flipX, Is.EqualTo(flip));
|
||||
Assert.That(orbitMaterial.GetTexture("_BodyTex"), Is.EqualTo(bodyRenderer.sprite.texture));
|
||||
restoredBodyPixels += AssertCycloneBodyAndFeetPixels(sourceCamera, bodyRenderer,
|
||||
world.Player.Visual.FootPosition, baseline, unmasked, masked, label);
|
||||
}
|
||||
Assert.That(restoredBodyPixels, Is.GreaterThan(0), "The mask must remove an actual body overlap in both Cyclone variants.");
|
||||
world.Player.Visual.ResetVisual();
|
||||
world.Player.Facing = Vector2.right;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sourceCamera != null)
|
||||
{
|
||||
sourceCamera.transform.SetPositionAndRotation(originalPosition, originalRotation);
|
||||
sourceCamera.orthographicSize = originalSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int AssertCycloneBodyAndFeetPixels(Camera camera, SpriteRenderer body,
|
||||
Vector3 foot, Color32[] baseline, Color32[] unmasked, Color32[] masked, string label)
|
||||
{
|
||||
Sprite sprite = body.sprite;
|
||||
// Read alpha through a temporary GPU copy; production sprite importers
|
||||
// deliberately keep CPU readability disabled.
|
||||
Texture2D texture = new(sprite.texture.width, sprite.texture.height, TextureFormat.RGBA32, false, true);
|
||||
RenderTexture alphaTarget = RenderTexture.GetTemporary(texture.width, texture.height, 0,
|
||||
RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
|
||||
RenderTexture previousTarget = RenderTexture.active;
|
||||
Graphics.Blit(sprite.texture, alphaTarget);
|
||||
RenderTexture.active = alphaTarget;
|
||||
texture.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
|
||||
texture.Apply();
|
||||
RenderTexture.active = previousTarget;
|
||||
RenderTexture.ReleaseTemporary(alphaTarget);
|
||||
Rect rect = sprite.textureRect;
|
||||
Vector2 pivot = sprite.pivot - sprite.textureRectOffset;
|
||||
Vector3 footLocal = body.transform.InverseTransformPoint(foot);
|
||||
float minY = footLocal.y + Defense25DComparisonSettings.CycloneBodyMaskFootClearancePixels / sprite.pixelsPerUnit;
|
||||
Matrix4x4 inverse = body.transform.worldToLocalMatrix;
|
||||
float depth = camera.WorldToScreenPoint(body.transform.position).z;
|
||||
Vector3 footScreen = camera.WorldToScreenPoint(foot);
|
||||
float cutoffY = camera.WorldToScreenPoint(body.transform.TransformPoint(new Vector3(footLocal.x, minY, 0))).y;
|
||||
int bodyPixels = 0, bodyMismatch = 0, restored = 0, feetPixels = 0, outsidePixels = 0;
|
||||
for (int y = 0; y < 360; y++)
|
||||
for (int x = 0; x < 640; x++)
|
||||
{
|
||||
int index = y * 640 + x;
|
||||
Vector3 local = inverse.MultiplyPoint3x4(camera.ScreenToWorldPoint(new Vector3(x + .5f, y + .5f, depth)));
|
||||
float px = local.x * (body.flipX ? -1f : 1f) * sprite.pixelsPerUnit + pivot.x;
|
||||
float py = local.y * (body.flipY ? -1f : 1f) * sprite.pixelsPerUnit + pivot.y;
|
||||
bool protectedBody = false;
|
||||
if (local.y >= minY && px >= 1 && py >= 1 && px < rect.width - 1 && py < rect.height - 1)
|
||||
{
|
||||
int tx = Mathf.FloorToInt(rect.x + px), ty = Mathf.FloorToInt(rect.y + py);
|
||||
// Check interior opaque pixels; exclude the rasterized silhouette boundary.
|
||||
protectedBody = texture.GetPixel(tx, ty).a >= .4f
|
||||
&& texture.GetPixel(tx - 1, ty).a >= .4f && texture.GetPixel(tx + 1, ty).a >= .4f
|
||||
&& texture.GetPixel(tx, ty - 1).a >= .4f && texture.GetPixel(tx, ty + 1).a >= .4f;
|
||||
}
|
||||
if (protectedBody)
|
||||
{
|
||||
bodyPixels++;
|
||||
if (!masked[index].Equals(baseline[index])) bodyMismatch++;
|
||||
if (!unmasked[index].Equals(baseline[index]) && masked[index].Equals(baseline[index])) restored++;
|
||||
}
|
||||
else if (!masked[index].Equals(baseline[index])) outsidePixels++;
|
||||
if (y < cutoffY && y > footScreen.y - 32 && Mathf.Abs(x - footScreen.x) < 48
|
||||
&& !masked[index].Equals(baseline[index])) feetPixels++;
|
||||
}
|
||||
Object.Destroy(texture);
|
||||
Assert.That(bodyPixels, Is.GreaterThan(20), label + " must sample the opaque torso/head.");
|
||||
Assert.That(bodyMismatch, Is.Zero, label + " protected body must match effect-hidden pixels.");
|
||||
Assert.That(feetPixels, Is.GreaterThan(10), label + " foreground feet arcs must remain visible.");
|
||||
Assert.That(outsidePixels, Is.GreaterThan(100), label + " outside orbit must remain visible.");
|
||||
Debug.Log($"CYCLONE_BODY_MASK {label} body={bodyPixels} mismatch={bodyMismatch} restored={restored} feet={feetPixels} outside={outsidePixels}");
|
||||
return restored;
|
||||
}
|
||||
|
||||
private static int CountCyclonePixels(Color32[] pixels) => pixels.Count(p => p.g > 90 && p.g > p.r * 1.7f && p.b > p.r * 1.2f && p.b < p.g);
|
||||
@@ -1100,6 +1319,30 @@ namespace BumpCombat.Tests
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private Color32[] CaptureCamera(Camera camera, string label)
|
||||
{
|
||||
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
|
||||
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
|
||||
Directory.CreateDirectory(directory);
|
||||
RenderTexture target = new(640, 360, 24, RenderTextureFormat.ARGB32);
|
||||
RenderTexture previous = RenderTexture.active;
|
||||
RenderTexture originalTarget = camera.targetTexture;
|
||||
camera.targetTexture = target;
|
||||
camera.Render();
|
||||
RenderTexture.active = target;
|
||||
Texture2D image = new(640, 360, TextureFormat.RGB24, false);
|
||||
image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
||||
image.Apply();
|
||||
Color32[] pixels = image.GetPixels32();
|
||||
File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG());
|
||||
RenderTexture.active = previous;
|
||||
camera.targetTexture = originalTarget;
|
||||
target.Release();
|
||||
Object.Destroy(target);
|
||||
Object.Destroy(image);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private void AssertCapturedGeometry(Color32[] pixels)
|
||||
{
|
||||
Color32 background = pixels[0];
|
||||
|
||||
Reference in New Issue
Block a user