Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs
T
2026-09-28 11:07:17 +09:00

337 lines
18 KiB
C#

using System.Collections;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DCombatFeedbackPlayModeTests
{
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private Defense25DView view;
private float previousTimeScale;
private float previousBlinkInterval;
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
previousBlinkInterval = GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval;
Time.timeScale = 1f;
settings = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first.");
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
GameObject viewObject = new("Feedback render camera");
Camera camera = viewObject.AddComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 6.5f;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(.07f, .09f, .11f, 1f);
view = viewObject.AddComponent<Defense25DView>();
world = new GameObject("Combat feedback world under test").AddComponent<Defense25DComparison>();
world.Initialize(settings, view);
camera.transform.position = new Vector3(0f, 12f, -12f);
camera.transform.LookAt(new Vector3(0f, settings.GroundY, 0f));
world.AutoSpawn = false;
world.ReadInput = false;
world.Player.ResetAt(Vector2.zero);
yield return new WaitForFixedUpdate();
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (view != null) Object.Destroy(view.gameObject);
if (settings != null) Object.Destroy(settings);
GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval = previousBlinkInterval;
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator HiddenArtifactContactCreatesOnlyTheAuthoredContactImpact()
{
Time.timeScale = .37f;
Defense25DCombatFeedback feedback = world.CombatFeedback;
Defense25DEnemy target = world.Enemies[0];
CombatHitResult contact = CombatHitResult.ForArtifactContact(
world.Player.gameObject, target.gameObject, ActiveArtifactEffect.Pulse, false,
HitSide.Front, Vector2.right, target.Actor.Position, DamageTag.Collision,
"feedback-contact-test");
feedback.NotifyValidHit(contact);
Assert.That(feedback.ActiveArtifactImpactCount, Is.EqualTo(1));
Assert.That(Time.timeScale, Is.EqualTo(.37f),
"A hidden/zero-damage contact gets its source sprite but must not request hit-stop.");
SpriteRenderer impact = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name.StartsWith("Artifact Impact "));
Assert.That(impact, Is.Not.Null);
Sprite[] authoredFrames = Resources.LoadAll<Sprite>(
CombatFeedback.GetArtifactImpactResourcePath(ActiveArtifactEffect.Pulse, false));
System.Array.Sort(authoredFrames, (a, b) => string.CompareOrdinal(a.name, b.name));
Assert.That(authoredFrames, Has.Length.EqualTo(CombatFeedback.ArtifactImpactFrameCount));
Assert.That(impact.sprite, Is.SameAs(authoredFrames[0]));
Assert.That(impact.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
yield return null;
}
[UnityTest]
public IEnumerator PlayerAlpha035UsesTransparentMaterialAndRendersIt()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback;
float blinkInterval = tuning.InvulnerabilityBlinkInterval;
SpriteRenderer player = world.Player.Visual.Renderer;
Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null);
Assert.That(feedback.TransparentEffectMaterial.GetFloat("_Cutoff"), Is.EqualTo(.001f).Within(.0001f));
Assert.That(feedback.TransparentEffectMaterial.GetColor("_BaseColor"), Is.EqualTo(Color.white));
feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right,
GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold);
// With the authored blink interval temporarily disabled, the same hurt/flash scene
// is the opaque capture baseline. Re-enabling it produces the source alpha-.35 phase.
tuning.InvulnerabilityBlinkInterval = 0f;
feedback.TickFeedback(0f, 0f);
yield return null;
Color[] opaqueCapture = CaptureView();
tuning.InvulnerabilityBlinkInterval = blinkInterval;
feedback.TickFeedback(0f, 0f);
yield return null;
Assert.That(player.color.a, Is.EqualTo(.35f).Within(.001f));
Assert.That(player.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial),
"The alpha test must select the unclipped, cloned transparent effect material.");
Color[] fadedCapture = CaptureView();
int changedPixels = 0;
for (int i = 0; i < opaqueCapture.Length; i++)
{
Color a = opaqueCapture[i];
Color b = fadedCapture[i];
if (Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g) + Mathf.Abs(a.b - b.b) > .08f)
changedPixels++;
}
Assert.That(changedPixels, Is.GreaterThan(0),
"A camera render capture must show the player changing at alpha .35; cutout clipping is not acceptable.");
tuning.InvulnerabilityBlinkInterval = blinkInterval;
feedback.ResetFeedback();
yield return null;
}
[UnityTest]
public IEnumerator HurtUsesSourceKnockbackDirectionForImpactOriginAndOwnsHitStop()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Vector2 playerPosition = world.Player.Position;
Vector2 knockbackDirection = new(.6f, .8f);
Vector3 worldPosition = world.Player.transform.position;
float heavyThreshold = GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold;
feedback.NotifyPlayerHurt(worldPosition, knockbackDirection, heavyThreshold);
SpriteRenderer impact = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Player Hurt Impact");
Assert.That(impact, Is.Not.Null);
Vector2 expectedOrigin = playerPosition - knockbackDirection.normalized * .18f;
Assert.That(impact.transform.position.x, Is.EqualTo(expectedOrigin.x).Within(.001f));
Assert.That(impact.transform.position.z, Is.EqualTo(expectedOrigin.y).Within(.001f));
Assert.That(feedback.ActivePlayerHurtImpactCount, Is.EqualTo(1));
Assert.That(Time.timeScale, Is.Zero);
feedback.ResetFeedback();
Assert.That(Time.timeScale, Is.EqualTo(1f), "Reset releases only the hit-stop owned by this feedback component.");
yield return null;
}
[UnityTest]
public IEnumerator ResetDoesNotChangeAnExternalPauseAndClearsOwnedVisuals()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Time.timeScale = 0f;
feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right, 0f);
feedback.ResetFeedback();
Assert.That(Time.timeScale, Is.Zero,
"Reset must not claim or restore a time scale that was already externally paused.");
Assert.That(feedback.ActiveEffectCount, Is.Zero);
Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null,
"Reset keeps the bound world's cloned material for the next event.");
yield return null;
}
[UnityTest]
public IEnumerator ChargeReadyFlashSwitchAndReleaseUseTheirAuthoredSheets()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse);
Assert.That(artifact, Is.Not.Null);
Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True);
feedback.NotifyChargeChanged(artifact, .5f, false);
Assert.That(feedback.IsChargingVisualVisible, Is.True);
feedback.NotifyChargeChanged(artifact, 1f, true);
Assert.That(feedback.IsReadyFlashVisible, Is.True,
"The four-frame Flash sheet is validated against FlashFrameCount, not the six-frame aura count.");
Texture2D flashSheet = Resources.Load<Texture2D>(
Defense25DCombatFeedbackConstants.ChargeResourceRoot + artifact.Effect
+ Defense25DCombatFeedbackConstants.ChargeFlashSuffix);
Assert.That(flashSheet, Is.Not.Null);
Assert.That(flashSheet.width,
Is.EqualTo(ArtifactChargeVisual.CellSize * ArtifactChargeVisual.FlashFrameCount));
Assert.That(flashSheet.height, Is.EqualTo(ArtifactChargeVisual.CellSize));
SpriteRenderer flash = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Charge "
+ Defense25DCombatFeedbackConstants.ChargeFlashSuffix);
Assert.That(flash, Is.Not.Null);
Assert.That(flash.sprite.rect.width, Is.EqualTo(ArtifactChargeVisual.CellSize));
feedback.NotifyArtifactSwitched(artifact);
SpriteRenderer switchPulse = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name.Contains("Artifact Charge ")
&& renderer.gameObject.name.Contains(Defense25DCombatFeedbackConstants.ChargeReleaseSuffix));
Assert.That(switchPulse, Is.Not.Null);
Assert.That(switchPulse.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
feedback.NotifyArtifactReleased(artifact, world.Player.Position, true);
SpriteRenderer release = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Release");
Assert.That(release, Is.Not.Null);
Assert.That(feedback.ActiveEffectCount, Is.GreaterThan(0));
feedback.ResetFeedback();
Assert.That(feedback.ActiveEffectCount, Is.Zero);
yield return null;
}
[UnityTest]
public IEnumerator GuardBlockedBumpAndTowerFeedbackUseOriginalSprites()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Defense25DEnemy target = world.Enemies[0];
feedback.NotifyGuardImpact(Vector2.left);
feedback.NotifyBlockedBump(target.Actor.Position, Vector2.right);
feedback.NotifyTowerImpact(DefenseTowerKind.Direct, target, target.Actor.Position);
SpriteRenderer guard = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Player Guard Block Impact");
SpriteRenderer blocked = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Blocked Bump");
SpriteRenderer tower = Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(renderer => renderer.gameObject.name == "Tower Impact Direct");
Assert.That(guard, Is.Not.Null);
Assert.That(blocked, Is.Not.Null);
Assert.That(blocked.flipX, Is.False);
Assert.That(tower, Is.Not.Null);
Assert.That(guard.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
Assert.That(blocked.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
Assert.That(tower.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial));
Sprite[] towerFrames = Resources.LoadAll<Sprite>(CombatFeedback.GetTowerImpactResourcePath(DefenseTowerKind.Direct));
Assert.That(towerFrames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount));
Assert.That(tower.sprite, Is.SameAs(towerFrames[0]));
feedback.ResetFeedback();
Assert.That(feedback.ActiveEffectCount, Is.Zero);
yield return null;
}
[UnityTest]
public IEnumerator ShieldPulseAndEnemyDustEffectsRemainWithinHeavyEffectCap()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
Defense25DEnemy target = world.Enemies[0];
Vector2 start = target.Actor.Position;
Vector2 finish = start + Vector2.right * .65f;
feedback.NotifyShield(.2f);
feedback.NotifyPulseGather(target, start, finish);
feedback.NotifyKnockbackCompleted(target.Actor, start, finish);
for (int i = 0; i < CombatFeedback.MaxConcurrentHeavyEffects + 4; i++)
feedback.NotifyShield(.2f);
Assert.That(feedback.ActiveHeavyEffectCount, Is.LessThanOrEqualTo(CombatFeedback.MaxConcurrentHeavyEffects));
Assert.That(Object.FindObjectsByType<LineRenderer>(FindObjectsSortMode.None)
.Any(line => line.gameObject.name == "Interaction Ring"), Is.True);
Assert.That(Object.FindObjectsByType<SpriteRenderer>(FindObjectsSortMode.None)
.Any(renderer => renderer.gameObject.name == "Pulse Gather Afterimage"), Is.True);
feedback.ResetFeedback();
Assert.That(feedback.ActiveHeavyEffectCount, Is.Zero);
yield return null;
}
[UnityTest]
public IEnumerator ShieldRingStartsAndFollowsThePlayerVisualGroundAnchor()
{
Defense25DCombatFeedback feedback = world.CombatFeedback;
world.SetPaused(false);
Vector3 foot = world.Player.Visual.FootPosition;
Vector2 initialAnchor = new(foot.x, foot.z);
Assert.That(Vector2.Distance(world.Player.Position, initialAnchor), Is.GreaterThan(.01f),
"Fixture must retain the player's authored foot-to-capsule offset so the old body anchor would fail.");
feedback.NotifyShield(.5f);
LineRenderer ring = Object.FindObjectsByType<LineRenderer>(FindObjectsSortMode.None)
.FirstOrDefault(line => line.gameObject.name == "Interaction Ring");
Assert.That(ring, Is.Not.Null);
Assert.That(ring.transform.position.x, Is.EqualTo(initialAnchor.x).Within(.001f));
Assert.That(ring.transform.position.z, Is.EqualTo(initialAnchor.y).Within(.001f));
Vector2 movedPosition = world.Player.Position + new Vector2(1.1f, -.65f);
world.Player.ResetAt(movedPosition);
Physics.SyncTransforms();
yield return null;
yield return null;
Vector3 movedFoot = world.Player.Visual.FootPosition;
feedback.TickFeedback(.016f, .016f);
Assert.That(ring.transform.position.x, Is.EqualTo(movedFoot.x).Within(.001f));
Assert.That(ring.transform.position.z, Is.EqualTo(movedFoot.z).Within(.001f));
Vector2 ringGround = Defense25DActor.Ground(ring.transform.position);
Assert.That(Vector2.Distance(ringGround, world.Player.Position), Is.GreaterThan(.01f));
yield return null;
}
private Color[] CaptureView()
{
RenderTexture previous = RenderTexture.active;
Texture2D capture = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGBA32, false);
view.Camera.Render();
RenderTexture.active = view.Texture;
capture.ReadPixels(new Rect(0f, 0f, Defense25DView.Width, Defense25DView.Height), 0, 0);
capture.Apply();
Color[] pixels = capture.GetPixels();
RenderTexture.active = previous;
Object.Destroy(capture);
return pixels;
}
private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack)
{
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
.OfType<Sprite>()
.OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame)
? frame : 0)
.ToArray();
return new ComparisonSpriteFrames
{
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack),
Hurt = Read("Hurt"), Death = Read("Death"),
};
}
}
}