2.5 update

This commit is contained in:
2026-09-28 11:07:17 +09:00
parent abd2da2057
commit 7978d82196
976 changed files with 111965 additions and 313 deletions
@@ -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];