337 lines
18 KiB
C#
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"),
|
|
};
|
|
}
|
|
}
|
|
}
|