Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: abfbaebd930ff944599ad17177316190
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0fa9e8fcdcfe4d94b961e82b71d876ae
|
||||
@@ -0,0 +1,355 @@
|
||||
using BumpCombat.Core;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class ArtifactBalanceStatusRegressionTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private GameObject owner;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
owner = new GameObject("Artifact Balance Status Tests");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ignite_UsesHalfSecondFirstTickAndIncludesThreeSecondBoundary()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Ignite Boundary Target");
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
|
||||
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 3f), Is.True);
|
||||
MethodInfo updateTimedEffects = GetUpdateTimedEffects();
|
||||
|
||||
for (int tick = 1; tick <= 6; tick++)
|
||||
{
|
||||
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
|
||||
|
||||
Assert.That(
|
||||
enemy.CurrentHealth,
|
||||
Is.EqualTo(100f - tick * 3f).Within(0.001f),
|
||||
$"Expected ignite tick {tick} at the {tick * 0.5f:0.0}s boundary.");
|
||||
}
|
||||
|
||||
Assert.That(enemy.IsIgnited, Is.False);
|
||||
Assert.That(enemy.IgniteRemaining, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ignite_ReapplicationKeepsStrongValueAndDoesNotDelayNextTick()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Ignite Reapply Target");
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
MethodInfo updateTimedEffects = GetUpdateTimedEffects();
|
||||
|
||||
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 5f), Is.True);
|
||||
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(95f).Within(0.001f));
|
||||
|
||||
Assert.That(enemy.ApplyIgnite(1f, 0.5f, 2f), Is.True);
|
||||
Assert.That(enemy.IgniteRemaining, Is.EqualTo(2.5f).Within(0.001f));
|
||||
|
||||
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
|
||||
Assert.That(
|
||||
enemy.CurrentHealth,
|
||||
Is.EqualTo(90f).Within(0.001f),
|
||||
"Reapplying ignite must not move the next scheduled tick.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ignite_ShockAmplificationFloorsTheFinalTickDamage()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Shocked Ignite Target");
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
|
||||
Assert.That(enemy.ApplyShock(1f, 0.2f), Is.True);
|
||||
Assert.That(enemy.ApplyIgnite(0.5f, 0.5f, 11f), Is.True);
|
||||
GetUpdateTimedEffects().Invoke(enemy, new object[] { 0.5f });
|
||||
|
||||
Assert.That(
|
||||
enemy.CurrentHealth,
|
||||
Is.EqualTo(87f),
|
||||
"An 11-damage tick amplified by shock is floored from 13.2 to 13 at application.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DamageTakenEffects_StackByMultiplicationAndOnlyNormalBumpsUseVulnerability()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Damage Taken Effects Target");
|
||||
|
||||
Assert.That(enemy.ApplyShock(4f, 0.2f), Is.True);
|
||||
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True);
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, false),
|
||||
Is.True);
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(88f).Within(0.001f));
|
||||
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, true),
|
||||
Is.True);
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(85f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DamageTakenEffects_FloorAfterShockAndVulnerability()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Truncated Status Target");
|
||||
Assert.That(enemy.ApplyShock(4f, 0.2f), Is.True);
|
||||
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True);
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(13f, Vector2.zero, 0f, false, true),
|
||||
Is.True);
|
||||
|
||||
Assert.That(
|
||||
enemy.LastReportedDamage,
|
||||
Is.EqualTo(19f),
|
||||
"13 × 1.2 × 1.25 = 19.5, with a single final floor to 19.");
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(81f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChainLightningDirectHit_UsesZeroAndOneShockProbabilityPaths()
|
||||
{
|
||||
GameObject playerObject = new("Chain Lightning Direct Hit Source");
|
||||
playerObject.transform.SetParent(owner.transform);
|
||||
playerObject.AddComponent<PlayerStats>();
|
||||
playerObject.AddComponent<Rigidbody2D>();
|
||||
ActiveArtifactController artifacts =
|
||||
playerObject.AddComponent<ActiveArtifactController>();
|
||||
typeof(ActiveArtifactController)
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(artifacts, null);
|
||||
MethodInfo applyArtifactHit = typeof(ActiveArtifactController).GetMethod(
|
||||
"ApplyArtifactHit",
|
||||
NonPublicInstance);
|
||||
Assert.That(applyArtifactHit, Is.Not.Null);
|
||||
|
||||
ActiveArtifactDefinition noShock = CreateChainDefinition(
|
||||
"chain-no-shock",
|
||||
0f);
|
||||
EnemyController noShockTarget = CreateEnemy("No Shock Target");
|
||||
SetAutoProperty(noShockTarget, "CurrentHealth", 100f);
|
||||
Assert.That(
|
||||
applyArtifactHit.Invoke(
|
||||
artifacts,
|
||||
new object[]
|
||||
{
|
||||
noShockTarget,
|
||||
noShock,
|
||||
1f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
false,
|
||||
false,
|
||||
0f,
|
||||
1,
|
||||
}),
|
||||
Is.True);
|
||||
Assert.That(noShockTarget.IsShocked, Is.False);
|
||||
|
||||
ActiveArtifactDefinition guaranteedShock = CreateChainDefinition(
|
||||
"chain-guaranteed-shock",
|
||||
1f);
|
||||
EnemyController guaranteedTarget = CreateEnemy("Guaranteed Shock Target");
|
||||
SetAutoProperty(guaranteedTarget, "CurrentHealth", 100f);
|
||||
Assert.That(
|
||||
applyArtifactHit.Invoke(
|
||||
artifacts,
|
||||
new object[]
|
||||
{
|
||||
guaranteedTarget,
|
||||
guaranteedShock,
|
||||
11f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
false,
|
||||
false,
|
||||
0f,
|
||||
2,
|
||||
}),
|
||||
Is.True);
|
||||
Assert.That(guaranteedTarget.IsShocked, Is.True);
|
||||
Assert.That(guaranteedTarget.CurrentHealth, Is.EqualTo(89f).Within(0.001f));
|
||||
Assert.That(
|
||||
applyArtifactHit.Invoke(
|
||||
artifacts,
|
||||
new object[]
|
||||
{
|
||||
guaranteedTarget,
|
||||
guaranteedShock,
|
||||
11f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
false,
|
||||
false,
|
||||
0f,
|
||||
3,
|
||||
}),
|
||||
Is.True);
|
||||
Assert.That(
|
||||
guaranteedTarget.CurrentHealth,
|
||||
Is.EqualTo(76f).Within(0.001f),
|
||||
"A shock applied by a direct hit amplifies the next hit from 11 to 13 damage.");
|
||||
|
||||
Object.DestroyImmediate(noShock);
|
||||
Object.DestroyImmediate(guaranteedShock);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TimedStatusReapplication_KeepsStrongestValuesAndCoexistsWithoutDuplicates()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Status Reapplication Target");
|
||||
|
||||
Assert.That(enemy.ApplyShock(4f, 0.3f), Is.True);
|
||||
Assert.That(enemy.ApplyShock(1f, 0.1f), Is.True);
|
||||
Assert.That(enemy.ShockRemaining, Is.EqualTo(4f).Within(0.001f));
|
||||
Assert.That(enemy.ShockIncrease, Is.EqualTo(0.3f).Within(0.001f));
|
||||
|
||||
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.4f), Is.True);
|
||||
Assert.That(enemy.ApplyBumpVulnerability(1f, 0.25f), Is.True);
|
||||
Assert.That(
|
||||
enemy.BumpVulnerabilityRemaining,
|
||||
Is.EqualTo(3f).Within(0.001f));
|
||||
Assert.That(
|
||||
enemy.BumpVulnerabilityIncrease,
|
||||
Is.EqualTo(0.4f).Within(0.001f));
|
||||
|
||||
Assert.That(enemy.IsShocked, Is.True);
|
||||
Assert.That(enemy.IsBumpVulnerable, Is.True);
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, true),
|
||||
Is.True);
|
||||
Assert.That(enemy.LastReportedDamage, Is.EqualTo(18f));
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(82f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ignite_UsesFractionalDamageSnapshotAndFloorsAfterShockAtTick()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Fractional Ignite Snapshot Target");
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
GameObject playerObject = new("Fractional Ignite Snapshot Source");
|
||||
playerObject.transform.SetParent(owner.transform);
|
||||
PlayerStats stats = playerObject.AddComponent<PlayerStats>();
|
||||
stats.AddModifier(new StatModifier(
|
||||
"collision-increased",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.5f));
|
||||
float snapshotDamage = stats.CalculateDamage(3f, DamageTag.Collision);
|
||||
Assert.That(snapshotDamage, Is.EqualTo(4.5f).Within(0.001f));
|
||||
|
||||
Assert.That(enemy.ApplyShock(2f, 0.2f), Is.True);
|
||||
Assert.That(enemy.ApplyIgnite(0.5f, 0.5f, snapshotDamage), Is.True);
|
||||
GetUpdateTimedEffects().Invoke(enemy, new object[] { 0.5f });
|
||||
|
||||
Assert.That(enemy.LastReportedDamage, Is.EqualTo(5f));
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(95f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ignite_ConsumesZeroDamageTickWithoutReplayingItAfterShock()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Zero Ignite Tick Target");
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
MethodInfo updateTimedEffects = GetUpdateTimedEffects();
|
||||
Assert.That(enemy.ApplyIgnite(2f, 1f, 0.9f), Is.True);
|
||||
|
||||
updateTimedEffects.Invoke(enemy, new object[] { 1f });
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(100f));
|
||||
|
||||
Assert.That(enemy.ApplyShock(2f, 1f), Is.True);
|
||||
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(100f), "The first zero-damage tick must not be replayed.");
|
||||
|
||||
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(99f), "Only the next scheduled tick is amplified and applied.");
|
||||
}
|
||||
|
||||
private EnemyController CreateEnemy(string name)
|
||||
{
|
||||
GameObject enemyObject = new(name);
|
||||
enemyObject.transform.SetParent(owner.transform);
|
||||
enemyObject.AddComponent<Rigidbody2D>();
|
||||
enemyObject.AddComponent<CircleCollider2D>();
|
||||
enemyObject.AddComponent<SpriteRenderer>();
|
||||
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
|
||||
EnemyAttack enemyAttack = enemyObject.GetComponent<EnemyAttack>();
|
||||
typeof(EnemyAttack)
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(enemyAttack, null);
|
||||
typeof(EnemyController)
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(enemy, null);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private static MethodInfo GetUpdateTimedEffects()
|
||||
{
|
||||
return typeof(EnemyController).GetMethod(
|
||||
"UpdateTimedEffects",
|
||||
NonPublicInstance);
|
||||
}
|
||||
|
||||
private static ActiveArtifactDefinition CreateChainDefinition(
|
||||
string id,
|
||||
float shockChance)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
|
||||
definition.Configure(
|
||||
id,
|
||||
id,
|
||||
"AR",
|
||||
ActiveArtifactEffect.ChainLightning,
|
||||
DamageTag.Collision,
|
||||
Color.white,
|
||||
Color.white,
|
||||
30f,
|
||||
30f,
|
||||
0.8f,
|
||||
1f,
|
||||
1f,
|
||||
2f,
|
||||
2f,
|
||||
0f,
|
||||
0f,
|
||||
normalElectrocuteChance: shockChance,
|
||||
normalElectrocuteIncrease: 0.2f,
|
||||
normalElectrocuteDuration: 3f);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static void SetAutoProperty(
|
||||
object target,
|
||||
string propertyName,
|
||||
object value)
|
||||
{
|
||||
target.GetType()
|
||||
.GetField(
|
||||
$"<{propertyName}>k__BackingField",
|
||||
NonPublicInstance)
|
||||
?.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e6c3a9bf54a4f3ca1d27b90e5f6c812
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class ArtifactChargeVisualTests
|
||||
{
|
||||
private static readonly ActiveArtifactEffect[] Effects =
|
||||
{
|
||||
ActiveArtifactEffect.Dash,
|
||||
ActiveArtifactEffect.Pulse,
|
||||
ActiveArtifactEffect.Phoenix,
|
||||
ActiveArtifactEffect.Cyclone,
|
||||
ActiveArtifactEffect.ThunderCrash,
|
||||
ActiveArtifactEffect.ChainLightning,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void ChargeResources_UseSix64PixelCellsAndPointImport()
|
||||
{
|
||||
foreach (ActiveArtifactEffect effect in Effects)
|
||||
{
|
||||
foreach (string phase in new[]
|
||||
{
|
||||
"Aura",
|
||||
"ReadyAura",
|
||||
"Flash",
|
||||
"Release",
|
||||
})
|
||||
{
|
||||
string path =
|
||||
$"Assets/_Project/Resources/Artifacts/Charge/"
|
||||
+ $"{effect}-{phase}-v1.png";
|
||||
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
|
||||
Assert.That(texture, Is.Not.Null, path);
|
||||
Assert.That(
|
||||
texture.width,
|
||||
Is.EqualTo(phase == "Flash" ? 256 : 384),
|
||||
path);
|
||||
Assert.That(texture.height, Is.EqualTo(64), path);
|
||||
Assert.That(texture.filterMode, Is.EqualTo(FilterMode.Point), path);
|
||||
Assert.That(texture.mipmapCount, Is.EqualTo(1), path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ControllerAwake_AutoAddsChargeVisualComponent()
|
||||
{
|
||||
GameObject owner = new("Artifact Charge Visual Harness");
|
||||
try
|
||||
{
|
||||
owner.AddComponent<PlayerStats>();
|
||||
owner.AddComponent<Rigidbody2D>();
|
||||
ActiveArtifactController controller =
|
||||
owner.AddComponent<ActiveArtifactController>();
|
||||
typeof(ActiveArtifactController)
|
||||
.GetMethod("Awake", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.Invoke(controller, null);
|
||||
|
||||
Assert.That(
|
||||
owner.GetComponent<ArtifactChargeVisual>(),
|
||||
Is.Not.Null);
|
||||
Assert.That(controller, Is.Not.Null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f7a11df6edc46a49ddf5677cc3de3b1
|
||||
@@ -0,0 +1,468 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class ArtifactColorShieldEditModeRegressionTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private readonly List<Object> createdObjects = new();
|
||||
private GameObject owner;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
owner = new GameObject("Artifact Color Shield Tests");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (owner != null)
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
|
||||
for (int i = createdObjects.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (createdObjects[i] != null)
|
||||
{
|
||||
Object.DestroyImmediate(createdObjects[i]);
|
||||
}
|
||||
}
|
||||
|
||||
createdObjects.Clear();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArtifactKindsMapToTheThreeShieldColors()
|
||||
{
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactColor(
|
||||
ActiveArtifactEffect.Dash),
|
||||
Is.EqualTo(ArtifactColor.Green));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactColor(
|
||||
ActiveArtifactEffect.Cyclone),
|
||||
Is.EqualTo(ArtifactColor.Green));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactColor(
|
||||
ActiveArtifactEffect.Pulse),
|
||||
Is.EqualTo(ArtifactColor.Red));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactColor(
|
||||
ActiveArtifactEffect.Phoenix),
|
||||
Is.EqualTo(ArtifactColor.Red));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactColor(
|
||||
ActiveArtifactEffect.ThunderCrash),
|
||||
Is.EqualTo(ArtifactColor.Blue));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactColor(
|
||||
ActiveArtifactEffect.ChainLightning),
|
||||
Is.EqualTo(ArtifactColor.Blue));
|
||||
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactPaletteColor(ArtifactColor.Green),
|
||||
Is.EqualTo(new Color32(103, 231, 178, 255)));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactPaletteColor(ArtifactColor.Red),
|
||||
Is.EqualTo(new Color32(255, 100, 100, 255)));
|
||||
Assert.That(
|
||||
ActiveArtifactDefinition.GetArtifactPaletteColor(ArtifactColor.Blue),
|
||||
Is.EqualTo(new Color32(104, 179, 255, 255)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EliteShieldWithoutOwnedArtifactsFallsBackToGreen()
|
||||
{
|
||||
CreateArtifactOwner();
|
||||
EnemyController enemy = CreateConfiguredEventEnemy(
|
||||
"Green Elite Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green));
|
||||
Assert.That(enemy.ShieldHitRequirement, Is.EqualTo(1));
|
||||
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1));
|
||||
Assert.That(
|
||||
enemy.RegisterArtifactContact(ArtifactColor.Red, 1),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
|
||||
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1));
|
||||
|
||||
Assert.That(
|
||||
enemy.RegisterArtifactContact(ArtifactColor.Green, 2),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
Assert.That(enemy.ShieldHitsRemaining, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EliteShieldStartsWithTheOnlyOwnedRedColor()
|
||||
{
|
||||
CreateArtifactOwner(ActiveArtifactEffect.Pulse);
|
||||
EnemyController enemy = CreateConfiguredEventEnemy(
|
||||
"Red Elite Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EliteShieldStartsWithTheOnlyOwnedBlueColor()
|
||||
{
|
||||
CreateArtifactOwner(ActiveArtifactEffect.ThunderCrash);
|
||||
EnemyController enemy = CreateConfiguredEventEnemy(
|
||||
"Blue Elite Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EliteShieldInitialColorIsRestrictedToDistinctOwnedColors()
|
||||
{
|
||||
CreateArtifactOwner(
|
||||
ActiveArtifactEffect.Cyclone,
|
||||
ActiveArtifactEffect.Pulse);
|
||||
EnemyController enemy = CreateConfiguredEventEnemy(
|
||||
"Green Red Elite Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(
|
||||
enemy.ShieldColor,
|
||||
Is.EqualTo(ArtifactColor.Green)
|
||||
.Or.EqualTo(ArtifactColor.Red));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DuplicateOwnedColorsDoNotChangeTheInitialColorChoice()
|
||||
{
|
||||
ActiveArtifactController artifacts = CreateArtifactOwner();
|
||||
ActiveArtifactDefinition green = CreateArtifact(
|
||||
ActiveArtifactEffect.Cyclone);
|
||||
ActiveArtifactDefinition red = CreateArtifact(
|
||||
ActiveArtifactEffect.Pulse);
|
||||
SetOwnedArtifacts(artifacts, green, red, green);
|
||||
|
||||
UnityEngine.Random.State previousState = UnityEngine.Random.state;
|
||||
try
|
||||
{
|
||||
UnityEngine.Random.InitState(7319);
|
||||
EnemyController withDuplicate = CreateConfiguredEventEnemy(
|
||||
"Duplicate Green Red Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
ArtifactColor duplicateChoice = withDuplicate.ShieldColor;
|
||||
|
||||
SetOwnedArtifacts(artifacts, green, red);
|
||||
UnityEngine.Random.InitState(7319);
|
||||
EnemyController withoutDuplicate = CreateConfiguredEventEnemy(
|
||||
"Distinct Green Red Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(
|
||||
withoutDuplicate.ShieldColor,
|
||||
Is.EqualTo(duplicateChoice));
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Random.state = previousState;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InitialShieldColorRemainsStableWhenEventEnemyIsReenabled()
|
||||
{
|
||||
CreateArtifactOwner(
|
||||
ActiveArtifactEffect.Pulse,
|
||||
ActiveArtifactEffect.ThunderCrash);
|
||||
EnemyController enemy = CreateConfiguredEventEnemy(
|
||||
"Stable Event Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
ArtifactColor initialColor = enemy.ShieldColor;
|
||||
|
||||
enemy.gameObject.SetActive(false);
|
||||
enemy.gameObject.SetActive(true);
|
||||
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(initialColor));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LaterShieldCyclesAdvanceFromTheSampledInitialColor()
|
||||
{
|
||||
CreateArtifactOwner(ActiveArtifactEffect.Pulse);
|
||||
EnemyController enemy = CreateConfiguredEventEnemy(
|
||||
"Red Cycle Shield",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red));
|
||||
OpenEliteGroggy(enemy, ArtifactColor.Red, 33);
|
||||
enemy.EndGroggyForProtection();
|
||||
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MidBossAndFinalBossUseTwoAndFourHitsOfCurrentColor()
|
||||
{
|
||||
EnemyController midBoss = CreateEnemy(
|
||||
"Red Mid Boss Shield",
|
||||
EnemyKind.NecroGolem);
|
||||
SetPrivateField(midBoss, "shieldColor", ArtifactColor.Red);
|
||||
|
||||
Assert.That(midBoss.ShieldHitRequirement, Is.EqualTo(2));
|
||||
Assert.That(
|
||||
midBoss.RegisterArtifactContact(ArtifactColor.Green, 10),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(midBoss.GroggyQualifyingContactCount, Is.Zero);
|
||||
Assert.That(
|
||||
midBoss.RegisterArtifactContact(ArtifactColor.Red, 11),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(
|
||||
midBoss.RegisterArtifactContact(ArtifactColor.Red, 12),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(midBoss.IsGroggy, Is.True);
|
||||
|
||||
EnemyController finalBoss = CreateEnemy(
|
||||
"Blue Final Boss Shield",
|
||||
EnemyKind.Necromancer);
|
||||
SetPrivateField(finalBoss, "shieldColor", ArtifactColor.Blue);
|
||||
Assert.That(finalBoss.ShieldHitRequirement, Is.EqualTo(4));
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.That(
|
||||
finalBoss.RegisterArtifactContact(ArtifactColor.Blue, 20 + i),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(finalBoss.IsGroggy, Is.False);
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
finalBoss.RegisterArtifactContact(ArtifactColor.Blue, 23),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(finalBoss.IsGroggy, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShieldColorCyclesGreenRedBlueAfterEachGroggyEnd()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy(
|
||||
"Cycling Elite Shield",
|
||||
EnemyKind.Werebear);
|
||||
|
||||
OpenEliteGroggy(enemy, ArtifactColor.Green, 30);
|
||||
enemy.EndGroggyForProtection();
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red));
|
||||
|
||||
OpenEliteGroggy(enemy, ArtifactColor.Red, 31);
|
||||
enemy.EndGroggyForProtection();
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
|
||||
|
||||
OpenEliteGroggy(enemy, ArtifactColor.Blue, 32);
|
||||
enemy.EndGroggyForProtection();
|
||||
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArtifactOwnershipAllowsOneOfTwoKindsPerColorAndThreeTotal()
|
||||
{
|
||||
GameObject player = new("Artifact Ownership");
|
||||
createdObjects.Add(player);
|
||||
player.AddComponent<PlayerStats>();
|
||||
player.AddComponent<Rigidbody2D>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.AddComponent<ActiveArtifactController>();
|
||||
artifacts.GetType()
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(artifacts, null);
|
||||
|
||||
ActiveArtifactDefinition greenA = CreateArtifact(
|
||||
ActiveArtifactEffect.Dash);
|
||||
ActiveArtifactDefinition greenB = CreateArtifact(
|
||||
ActiveArtifactEffect.Cyclone);
|
||||
ActiveArtifactDefinition red = CreateArtifact(
|
||||
ActiveArtifactEffect.Pulse);
|
||||
ActiveArtifactDefinition blue = CreateArtifact(
|
||||
ActiveArtifactEffect.ThunderCrash);
|
||||
artifacts.Configure(new[] { greenA, greenB, red, blue });
|
||||
|
||||
Assert.That(artifacts.MaxOwnedArtifacts, Is.EqualTo(3));
|
||||
Assert.That(artifacts.MaxArtifactsPerColor, Is.EqualTo(1));
|
||||
Assert.That(artifacts.TryAddArtifact(greenA), Is.True);
|
||||
Assert.That(artifacts.TryAddArtifact(greenB), Is.False);
|
||||
Assert.That(artifacts.TryAddArtifact(red), Is.True);
|
||||
Assert.That(artifacts.TryAddArtifact(blue), Is.True);
|
||||
Assert.That(artifacts.OwnedArtifactCount, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
private void OpenEliteGroggy(
|
||||
EnemyController enemy,
|
||||
ArtifactColor color,
|
||||
int castIdentity)
|
||||
{
|
||||
Assert.That(
|
||||
enemy.RegisterArtifactContact(color, castIdentity),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
}
|
||||
|
||||
private EnemyController CreateEnemy(string name, EnemyKind kind)
|
||||
{
|
||||
EnemyDefinition definition =
|
||||
ScriptableObject.CreateInstance<EnemyDefinition>();
|
||||
definition.Configure(
|
||||
kind,
|
||||
EnemyAttackShape.Body,
|
||||
100f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0);
|
||||
createdObjects.Add(definition);
|
||||
|
||||
GameObject enemyObject = new(name);
|
||||
enemyObject.transform.SetParent(owner.transform);
|
||||
enemyObject.AddComponent<Rigidbody2D>();
|
||||
enemyObject.AddComponent<CircleCollider2D>();
|
||||
enemyObject.AddComponent<SpriteRenderer>();
|
||||
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
|
||||
enemy.Configure(definition, null);
|
||||
enemyObject.GetComponent<EnemyAttack>()
|
||||
.GetType()
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(enemyObject.GetComponent<EnemyAttack>(), null);
|
||||
enemy.GetType()
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(enemy, null);
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private EnemyController CreateConfiguredEventEnemy(
|
||||
string name,
|
||||
EnemyKind kind)
|
||||
{
|
||||
EnemyController enemy = CreateEnemy(name, kind);
|
||||
enemy.ConfigureRunEventEnemy(
|
||||
RunTimedEvent.Elite,
|
||||
RunEventEnemyTuning.Create(
|
||||
kind,
|
||||
1f,
|
||||
0,
|
||||
1f,
|
||||
Color.white,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
true));
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private ActiveArtifactController CreateArtifactOwner(
|
||||
params ActiveArtifactEffect[] effects)
|
||||
{
|
||||
GameObject player = new("Shield Color Artifact Owner");
|
||||
player.transform.SetParent(owner.transform);
|
||||
player.AddComponent<PlayerStats>();
|
||||
player.AddComponent<Rigidbody2D>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.AddComponent<ActiveArtifactController>();
|
||||
artifacts.GetType()
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(artifacts, null);
|
||||
|
||||
ActiveArtifactDefinition[] definitions =
|
||||
new ActiveArtifactDefinition[effects.Length];
|
||||
for (int i = 0; i < effects.Length; i++)
|
||||
{
|
||||
definitions[i] = CreateArtifact(effects[i]);
|
||||
}
|
||||
|
||||
artifacts.Configure(definitions);
|
||||
for (int i = 0; i < definitions.Length; i++)
|
||||
{
|
||||
Assert.That(artifacts.TryAddArtifact(definitions[i]), Is.True);
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private static void SetOwnedArtifacts(
|
||||
ActiveArtifactController artifacts,
|
||||
params ActiveArtifactDefinition[] definitions)
|
||||
{
|
||||
FieldInfo field = typeof(ActiveArtifactController).GetField(
|
||||
"ownedArtifacts",
|
||||
NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null);
|
||||
List<ActiveArtifactDefinition> owned =
|
||||
(List<ActiveArtifactDefinition>)field.GetValue(artifacts);
|
||||
owned.Clear();
|
||||
owned.AddRange(definitions);
|
||||
}
|
||||
|
||||
private ActiveArtifactDefinition CreateArtifact(
|
||||
ActiveArtifactEffect effect)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
|
||||
FieldInfo field = typeof(ActiveArtifactDefinition).GetField(
|
||||
"effect",
|
||||
NonPublicInstance);
|
||||
field.SetValue(definition, effect);
|
||||
FieldInfo idField = typeof(ActiveArtifactDefinition).GetField(
|
||||
"artifactId",
|
||||
NonPublicInstance);
|
||||
idField.SetValue(
|
||||
definition,
|
||||
$"color-test-{effect}-{createdObjects.Count}");
|
||||
createdObjects.Add(definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static void SetPrivateField(
|
||||
object target,
|
||||
string fieldName,
|
||||
object value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(
|
||||
fieldName,
|
||||
NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null, $"Missing {fieldName} field.");
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static void SetAutoProperty(
|
||||
object target,
|
||||
string propertyName,
|
||||
object value)
|
||||
{
|
||||
SetPrivateField(target, $"<{propertyName}>k__BackingField", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f17ae9b2e223dc4f8a9dc40a2659d5f
|
||||
@@ -0,0 +1,367 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class ArtifactGroggyEditModeRegressionTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private readonly List<UnityEngine.Object> createdObjects = new();
|
||||
private GameObject owner;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
owner = new GameObject("Artifact Groggy EditMode Tests");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (owner != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(owner);
|
||||
}
|
||||
|
||||
for (int i = createdObjects.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (createdObjects[i] != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(createdObjects[i]);
|
||||
}
|
||||
}
|
||||
|
||||
createdObjects.Clear();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnemyKindInfersGroggyTierAndContactRequirements()
|
||||
{
|
||||
EnemyController elite = CreateEnemy("Elite", EnemyKind.ArmoredSkeleton);
|
||||
EnemyController midBoss = CreateEnemy(
|
||||
"Mid Boss",
|
||||
EnemyKind.GreatswordSkeleton);
|
||||
EnemyController finalBoss = CreateEnemy(
|
||||
"Final Boss",
|
||||
EnemyKind.Necromancer);
|
||||
|
||||
Assert.That(elite.GroggyTier, Is.EqualTo(RunTimedEvent.Elite));
|
||||
Assert.That(elite.ShieldHitRequirement, Is.EqualTo(1));
|
||||
Assert.That(elite.GroggyRequiredContactCount, Is.EqualTo(1));
|
||||
|
||||
Assert.That(midBoss.GroggyTier, Is.EqualTo(RunTimedEvent.MidBoss));
|
||||
Assert.That(midBoss.ShieldHitRequirement, Is.EqualTo(2));
|
||||
Assert.That(midBoss.GroggyRequiredContactCount, Is.EqualTo(2));
|
||||
|
||||
Assert.That(finalBoss.GroggyTier, Is.EqualTo(RunTimedEvent.FinalBoss));
|
||||
Assert.That(finalBoss.ShieldHitRequirement, Is.EqualTo(4));
|
||||
Assert.That(finalBoss.GroggyRequiredContactCount, Is.EqualTo(4));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EliteGroggyRequirement_ReadsChangedEnemySettings()
|
||||
{
|
||||
EnemyConstants constants = GameplayConstants.Current.Enemies;
|
||||
int originalRequirement = constants.EliteGroggyRequiredMatchingHits;
|
||||
try
|
||||
{
|
||||
constants.EliteGroggyRequiredMatchingHits = 3;
|
||||
EnemyController enemy = CreateEnemy(
|
||||
"Elite Changed Threshold",
|
||||
EnemyKind.ArmoredSkeleton);
|
||||
|
||||
Assert.That(enemy.ShieldHitRequirement, Is.EqualTo(3));
|
||||
Assert.That(enemy.GroggyRequiredContactCount, Is.EqualTo(3));
|
||||
}
|
||||
finally
|
||||
{
|
||||
constants.EliteGroggyRequiredMatchingHits = originalRequirement;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NormalEnemyRemainsDamageableWithoutGroggyShield()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Normal", EnemyKind.Skeleton);
|
||||
float healthBefore = enemy.CurrentHealth;
|
||||
|
||||
Assert.That(enemy.IsGroggyTierGated, Is.False);
|
||||
Assert.That(enemy.IsDamageInvulnerable, Is.False);
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(5f, Vector2.right, 0f, false),
|
||||
Is.True);
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore - 5f));
|
||||
Assert.That(
|
||||
enemy.TryTakeArtifactHit(
|
||||
7f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
ArtifactColor.Blue,
|
||||
1,
|
||||
false),
|
||||
Is.EqualTo(EnemyArtifactHitResult.DamageApplied));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GroggyShieldBlocksNormalArtifactAndDotDamageBeforeOpening()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Elite Gate", EnemyKind.Werewolf);
|
||||
float healthBefore = enemy.CurrentHealth;
|
||||
|
||||
Assert.That(enemy.IsDamageInvulnerable, Is.True);
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(5f, Vector2.right, 0f, false),
|
||||
Is.False);
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
Assert.That(enemy.ApplyIgnite(1f, 0.5f, 4f), Is.True);
|
||||
InvokeTimedEffects(enemy, 0.5f);
|
||||
Assert.That(
|
||||
enemy.CurrentHealth,
|
||||
Is.EqualTo(healthBefore),
|
||||
"A DOT tick must not bypass the groggy shield.");
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
|
||||
|
||||
Assert.That(
|
||||
enemy.TryTakeArtifactHit(
|
||||
9f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
ArtifactColor.Red,
|
||||
1,
|
||||
false),
|
||||
Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(enemy.LastAppliedDamage, Is.Zero);
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
Assert.That(enemy.IsGroggy, Is.False);
|
||||
Assert.That(
|
||||
enemy.TryTakeArtifactHit(
|
||||
9f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
ArtifactColor.Green,
|
||||
2,
|
||||
false),
|
||||
Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
Assert.That(enemy.IsDamageInvulnerable, Is.False);
|
||||
|
||||
Assert.That(
|
||||
enemy.TryTakeDamage(5f, Vector2.right, 0f, false),
|
||||
Is.True);
|
||||
Assert.That(
|
||||
enemy.TryTakeArtifactHit(
|
||||
7f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
ArtifactColor.Blue,
|
||||
3,
|
||||
false),
|
||||
Is.EqualTo(EnemyArtifactHitResult.DamageApplied));
|
||||
|
||||
float healthAfterDirectHits = enemy.CurrentHealth;
|
||||
InvokeTimedEffects(enemy, 0.5f);
|
||||
Assert.That(
|
||||
enemy.CurrentHealth,
|
||||
Is.LessThan(healthAfterDirectHits),
|
||||
"The same DOT API is allowed to tick after groggy opens.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MidBossNeedsTwoMatchingColorContacts()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy(
|
||||
"Mid Boss Threshold",
|
||||
EnemyKind.NecroGolem);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 11), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.False);
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1));
|
||||
|
||||
Assert.That(
|
||||
Contact(enemy, ArtifactColor.Green, 12),
|
||||
Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(2));
|
||||
Assert.That(enemy.IsDamageInvulnerable, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FinalBossRequiresFourMatchingColorContacts()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy(
|
||||
"Final Boss Four Singles",
|
||||
EnemyKind.Necromancer);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Red, 21), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Blue, 22), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Blue, 23), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Red, 24), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
|
||||
Assert.That(enemy.IsGroggy, Is.False);
|
||||
Assert.That(enemy.IsDamageInvulnerable, Is.True);
|
||||
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(4));
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 25), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 26), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 27), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 28), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FinalBossFourCastsOfOneColorOpenGroggy()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy(
|
||||
"Final Boss Four Green Activations",
|
||||
EnemyKind.Necromancer);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 31), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 32), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 33), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(
|
||||
Contact(enemy, ArtifactColor.Green, 34),
|
||||
Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(4));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SameCastMultiTickIsDeduplicatedPerEnemy()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy(
|
||||
"Mid Boss Dedup",
|
||||
EnemyKind.GreatswordSkeleton);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 41), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(
|
||||
Contact(enemy, ArtifactColor.Green, 41),
|
||||
Is.EqualTo(EnemyArtifactHitResult.Rejected));
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1));
|
||||
Assert.That(enemy.IsGroggy, Is.False);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 42), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GroggyHitsDoNotExtendOrPrechargeTheNextCycle()
|
||||
{
|
||||
EnemyController enemy = CreateEnemy("Groggy Rearm", EnemyKind.Werebear);
|
||||
|
||||
Assert.That(Contact(enemy, ArtifactColor.Green, 51), Is.EqualTo(EnemyArtifactHitResult.Shielded));
|
||||
float remaining = enemy.GroggyTimeRemaining;
|
||||
|
||||
Assert.That(
|
||||
Contact(enemy, ArtifactColor.Blue, 52),
|
||||
Is.EqualTo(EnemyArtifactHitResult.DamageApplied));
|
||||
Assert.That(enemy.GroggyTimeRemaining, Is.EqualTo(remaining).Within(0.001f));
|
||||
Assert.That(enemy.ApplyStun(2f), Is.False);
|
||||
Assert.That(enemy.IsStunned, Is.False);
|
||||
|
||||
enemy.EndGroggyForProtection();
|
||||
Assert.That(enemy.IsGroggy, Is.False);
|
||||
Assert.That(enemy.IsDamageInvulnerable, Is.True);
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
|
||||
|
||||
Assert.That(
|
||||
enemy.RegisterArtifactContact(ArtifactColor.Blue, 52),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Ignored),
|
||||
"A cast that first contacted during groggy must not precharge the next shield.");
|
||||
Assert.That(
|
||||
enemy.RegisterArtifactContact(ArtifactColor.Red, 53),
|
||||
Is.EqualTo(EnemyArtifactContactResult.Shielded));
|
||||
Assert.That(enemy.IsGroggy, Is.True);
|
||||
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
private EnemyController CreateEnemy(string name, EnemyKind kind)
|
||||
{
|
||||
EnemyDefinition definition =
|
||||
ScriptableObject.CreateInstance<EnemyDefinition>();
|
||||
definition.Configure(
|
||||
kind,
|
||||
EnemyAttackShape.Body,
|
||||
100f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0);
|
||||
createdObjects.Add(definition);
|
||||
|
||||
GameObject enemyObject = new(name);
|
||||
enemyObject.transform.SetParent(owner.transform);
|
||||
enemyObject.AddComponent<Rigidbody2D>();
|
||||
enemyObject.AddComponent<CircleCollider2D>();
|
||||
enemyObject.AddComponent<SpriteRenderer>();
|
||||
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
|
||||
enemy.Configure(definition, null);
|
||||
|
||||
EnemyAttack enemyAttack = enemyObject.GetComponent<EnemyAttack>();
|
||||
enemyAttack.GetType()
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(enemyAttack, null);
|
||||
enemy.GetType()
|
||||
.GetMethod("Awake", NonPublicInstance)
|
||||
?.Invoke(enemy, null);
|
||||
SetAutoProperty(enemy, "CurrentHealth", 100f);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private static EnemyArtifactHitResult Contact(
|
||||
EnemyController enemy,
|
||||
ArtifactColor artifactColor,
|
||||
int castIdentity)
|
||||
{
|
||||
return enemy.TryTakeArtifactHit(
|
||||
10f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
artifactColor,
|
||||
castIdentity,
|
||||
false);
|
||||
}
|
||||
|
||||
private static void InvokeTimedEffects(
|
||||
EnemyController enemy,
|
||||
float deltaTime)
|
||||
{
|
||||
MethodInfo method = typeof(EnemyController).GetMethod(
|
||||
"UpdateTimedEffects",
|
||||
NonPublicInstance);
|
||||
Assert.That(method, Is.Not.Null);
|
||||
method.Invoke(enemy, new object[] { deltaTime });
|
||||
}
|
||||
|
||||
private static void SetAutoProperty(
|
||||
object target,
|
||||
string propertyName,
|
||||
object value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(
|
||||
$"<{propertyName}>k__BackingField",
|
||||
NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null, $"Missing {propertyName} backing field.");
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b6bbf5d18ad4d4cb2f869734fa2e8b1
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "BumpCombat.Tests",
|
||||
"rootNamespace": "BumpCombat.Tests",
|
||||
"references": [
|
||||
"BumpCombat.Runtime"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"optionalUnityReferences": [
|
||||
"TestAssemblies"
|
||||
],
|
||||
"autoReferenced": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d730b0dbebc910d4db14efbbd2217c51
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,445 @@
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class BumpCombatMathTests
|
||||
{
|
||||
[Test]
|
||||
public void ClassifySide_WithinFrontNinetyDegrees_ReturnsFront()
|
||||
{
|
||||
Vector2 enemyFacing = Vector2.right;
|
||||
Vector2 enemyToPlayer = DirectionAtDegrees(44f);
|
||||
|
||||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||||
Is.EqualTo(HitSide.Front));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClassifySide_OutsideFrontNinetyDegrees_ReturnsSide()
|
||||
{
|
||||
Vector2 enemyFacing = Vector2.right;
|
||||
Vector2 enemyToPlayer = DirectionAtDegrees(46f);
|
||||
|
||||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||||
Is.EqualTo(HitSide.Side));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClassifySide_WithinBackOneHundredTwentyDegrees_ReturnsBack()
|
||||
{
|
||||
Vector2 enemyFacing = Vector2.right;
|
||||
Vector2 enemyToPlayer = DirectionAtDegrees(121f);
|
||||
|
||||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||||
Is.EqualTo(HitSide.Back));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClassifySide_OutsideBackOneHundredTwentyDegrees_ReturnsSide()
|
||||
{
|
||||
Vector2 enemyFacing = Vector2.right;
|
||||
Vector2 enemyToPlayer = DirectionAtDegrees(119f);
|
||||
|
||||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||||
Is.EqualTo(HitSide.Side));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplySideMultiplier_BackHit_IsExactlyOnePointFiveTimesDamage()
|
||||
{
|
||||
Assert.That(BumpCombatMath.ApplySideMultiplier(10f, HitSide.Back), Is.EqualTo(15f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplySideMultiplier_SideHit_IsExactlyOnePointTwoTimesDamage()
|
||||
{
|
||||
Assert.That(BumpCombatMath.ApplySideMultiplier(10f, HitSide.Side), Is.EqualTo(12f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplySideMultiplier_OffsetFrontHit_IsExactlyOnePointTwoTimesDamage()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.ApplySideMultiplier(10f, HitSide.Front, true),
|
||||
Is.EqualTo(12f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplySideMultiplier_CenteredFrontHit_DoesNotChangeDamage()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.ApplySideMultiplier(10.5f, HitSide.Front),
|
||||
Is.EqualTo(10.5f));
|
||||
}
|
||||
|
||||
[TestCase(HitSide.Back, 16.5f)]
|
||||
[TestCase(HitSide.Side, 13.2f)]
|
||||
public void ApplySideMultiplier_PreservesFractionalPositionBonus(
|
||||
HitSide side,
|
||||
float expectedDamage)
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.ApplySideMultiplier(11f, side),
|
||||
Is.EqualTo(expectedDamage).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyRepeatedMultiplier_PreservesEveryFractionalChainDepth()
|
||||
{
|
||||
Assert.That(
|
||||
DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 0),
|
||||
Is.EqualTo(10f));
|
||||
Assert.That(
|
||||
DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 1),
|
||||
Is.EqualTo(12f));
|
||||
Assert.That(
|
||||
DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 2),
|
||||
Is.EqualTo(14.4f).Within(0.001f));
|
||||
float depthThreeDamage = DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 3);
|
||||
Assert.That(
|
||||
depthThreeDamage,
|
||||
Is.EqualTo(17.28f).Within(0.001f),
|
||||
"Each link applies ×1.2 to the previous fractional damage value.");
|
||||
Assert.That(DamageCalculator.FinalizeDamage(depthThreeDamage), Is.EqualTo(17));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyDamageTakenEffects_FinalizesAfterShockAndVulnerability()
|
||||
{
|
||||
float damage = DamageCalculator.ApplyDamageTakenEffects(
|
||||
13f,
|
||||
0.2f,
|
||||
true,
|
||||
0.25f,
|
||||
true);
|
||||
|
||||
Assert.That(damage, Is.EqualTo(19f), "13 × 1.2 × 1.25 = 19.5, then final floor.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FinalizeDamage_DoesNotDropIntermediateDamageBelowOne()
|
||||
{
|
||||
float intermediateDamage = DamageCalculator.ApplyMultiplier(0.9f, 2f);
|
||||
|
||||
Assert.That(intermediateDamage, Is.EqualTo(1.8f).Within(0.001f));
|
||||
Assert.That(DamageCalculator.FinalizeDamage(intermediateDamage), Is.EqualTo(1f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FormatDamage_FloorsOnlyTheDisplayedDamage()
|
||||
{
|
||||
Assert.That(DamageCalculator.FormatDamage(14.5f), Is.EqualTo("14"));
|
||||
}
|
||||
|
||||
[TestCase(HitSide.Front)]
|
||||
[TestCase(HitSide.Side)]
|
||||
public void PlayerImpactRecoil_NonBackHit_IsApplied(HitSide side)
|
||||
{
|
||||
Assert.That(BumpCombatMath.AppliesPlayerImpactRecoil(side), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerImpactRecoil_BackHit_IsNotApplied()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.AppliesPlayerImpactRecoil(HitSide.Back),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerImpactRecoil_EventEnemyBackHit_IsApplied()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.AppliesPlayerImpactRecoil(HitSide.Back, true),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
[TestCase(1.2f, 0.625f, false, false)]
|
||||
[TestCase(0.625f, 0.625f, false, true)]
|
||||
[TestCase(0f, 0f, false, true)]
|
||||
[TestCase(0.4f, 0.625f, false, true)]
|
||||
[TestCase(0.1f, 0.625f, true, false)]
|
||||
public void AttackAnimationLead_StartsOnceAtLeadTime(
|
||||
float warningTimeRemaining,
|
||||
float leadTime,
|
||||
bool animationStarted,
|
||||
bool expected)
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.ShouldStartAttackAnimation(
|
||||
warningTimeRemaining,
|
||||
leadTime,
|
||||
animationStarted),
|
||||
Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HorizontalRootDirection_UsesOnlyTheLockedLeftRightSign()
|
||||
{
|
||||
Assert.That(
|
||||
EnemyAttack.ResolveAttackDirection(
|
||||
EnemyAttackDirectionMode.HorizontalRoot,
|
||||
new Vector2(1f, 1f),
|
||||
Vector2.left),
|
||||
Is.EqualTo(Vector2.right));
|
||||
Assert.That(
|
||||
EnemyAttack.ResolveAttackDirection(
|
||||
EnemyAttackDirectionMode.HorizontalRoot,
|
||||
new Vector2(-1f, 1f),
|
||||
Vector2.right),
|
||||
Is.EqualTo(Vector2.left));
|
||||
Assert.That(
|
||||
EnemyAttack.ResolveAttackDirection(
|
||||
EnemyAttackDirectionMode.LockedDirection,
|
||||
new Vector2(1f, 1f),
|
||||
Vector2.left),
|
||||
Is.EqualTo(new Vector2(1f, 1f).normalized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HorizontalRootOrigin_UsesWorldUpForLocalVerticalOffset()
|
||||
{
|
||||
Vector2 rightOrigin = EnemyAttack.GetAttackOrigin(
|
||||
Vector2.zero,
|
||||
Vector2.right,
|
||||
new Vector2(0.05f, -0.05f),
|
||||
EnemyAttackDirectionMode.HorizontalRoot);
|
||||
Vector2 leftOrigin = EnemyAttack.GetAttackOrigin(
|
||||
Vector2.zero,
|
||||
Vector2.left,
|
||||
new Vector2(0.05f, -0.05f),
|
||||
EnemyAttackDirectionMode.HorizontalRoot);
|
||||
|
||||
Assert.That(rightOrigin, Is.EqualTo(new Vector2(0.05f, -0.05f)));
|
||||
Assert.That(leftOrigin, Is.EqualTo(new Vector2(-0.05f, -0.05f)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ContactWindow_UsesHalfOpenActiveInterval()
|
||||
{
|
||||
EnemyAttackPattern pattern = EnemyAttackPattern.Create(
|
||||
"Attack",
|
||||
EnemyAttackShape.Box,
|
||||
1f,
|
||||
0.25f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
0f);
|
||||
|
||||
Assert.That(pattern.IsContactWindowActive(0f, 0.25f), Is.True);
|
||||
Assert.That(pattern.IsContactWindowActive(0.24999f, 0.25f), Is.True);
|
||||
Assert.That(pattern.IsContactWindowActive(0.25f, 0.25f), Is.False);
|
||||
Assert.That(pattern.IsContactWindowActive(0.27f, 0.25f), Is.False);
|
||||
Assert.That(pattern.IsContactWindowActive(-0.01f, 0.25f), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculateLateralOffset_CenteredApproach_ReturnsZero()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.CalculateLateralOffset(
|
||||
Vector2.right,
|
||||
Vector2.zero,
|
||||
Vector2.right),
|
||||
Is.EqualTo(0f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsOffsetHit_FrontApproachPastThreshold_ReturnsTrue()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.IsOffsetHit(
|
||||
HitSide.Front,
|
||||
Vector2.right,
|
||||
Vector2.zero,
|
||||
new Vector2(0.4f, 0.17f),
|
||||
0.16f),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsOffsetHit_FrontApproachBelowThreshold_ReturnsFalse()
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.IsOffsetHit(
|
||||
HitSide.Front,
|
||||
Vector2.right,
|
||||
Vector2.zero,
|
||||
new Vector2(0.4f, 0.15f),
|
||||
0.16f),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[TestCase(HitSide.Side)]
|
||||
[TestCase(HitSide.Back)]
|
||||
public void IsOffsetHit_NonFrontSide_ReturnsFalse(HitSide side)
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatMath.IsOffsetHit(
|
||||
side,
|
||||
Vector2.right,
|
||||
Vector2.zero,
|
||||
new Vector2(0.4f, 0.2f),
|
||||
0.16f),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WeaponEnemy_AttackStates_DoNotAllowStateMovement()
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.AllowsStateMovement(EnemyState.Warning, EnemyAttackShape.Box),
|
||||
Is.False);
|
||||
Assert.That(
|
||||
EnemyController.AllowsStateMovement(EnemyState.Active, EnemyAttackShape.Box),
|
||||
Is.False);
|
||||
Assert.That(
|
||||
EnemyController.AllowsStateMovement(EnemyState.Recovery, EnemyAttackShape.Box),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Slime_ActiveState_AllowsLockedDirectionDash()
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.AllowsStateMovement(EnemyState.Active, EnemyAttackShape.Body),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
[TestCase(EnemyState.Chase)]
|
||||
[TestCase(EnemyState.Warning)]
|
||||
[TestCase(EnemyState.Recovery)]
|
||||
public void Slime_NonActiveState_DoesNotDealContactDamage(EnemyState state)
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.AllowsContactDamage(state, EnemyAttackShape.Body),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Slime_ActiveState_DealsContactDamage()
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.AllowsContactDamage(
|
||||
EnemyState.Active,
|
||||
EnemyAttackShape.Body),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
[TestCase(EnemyState.Warning)]
|
||||
[TestCase(EnemyState.Active)]
|
||||
public void BackHit_AttemptedAttackState_IsInterruptible(EnemyState state)
|
||||
{
|
||||
Assert.That(EnemyController.CanBackHitInterrupt(state), Is.True);
|
||||
}
|
||||
|
||||
[TestCase(EnemyState.Spawn)]
|
||||
[TestCase(EnemyState.Chase)]
|
||||
[TestCase(EnemyState.Recovery)]
|
||||
[TestCase(EnemyState.Dead)]
|
||||
public void BackHit_NonAttackState_IsNotInterruptible(EnemyState state)
|
||||
{
|
||||
Assert.That(EnemyController.CanBackHitInterrupt(state), Is.False);
|
||||
}
|
||||
|
||||
[TestCase(EnemyAttackShape.Box)]
|
||||
[TestCase(EnemyAttackShape.Cone)]
|
||||
[TestCase(EnemyAttackShape.TargetCircle)]
|
||||
public void WeaponAttackShapes_NeverDealBodyContactDamage(
|
||||
EnemyAttackShape attackShape)
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.AllowsContactDamage(
|
||||
EnemyState.Active,
|
||||
attackShape),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HeavyEnemies_HaveMorePlayerKnockbackThanSlime()
|
||||
{
|
||||
float slimeDistance =
|
||||
EnemyController.GetPlayerKnockbackDistance(EnemyKind.Slime);
|
||||
|
||||
Assert.That(
|
||||
EnemyController.GetPlayerKnockbackDistance(EnemyKind.Lancer),
|
||||
Is.GreaterThan(slimeDistance));
|
||||
Assert.That(
|
||||
EnemyController.GetPlayerKnockbackDistance(
|
||||
EnemyKind.GreatswordSkeleton),
|
||||
Is.GreaterThan(slimeDistance));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllEnemies_ChaseState_AllowsMovement()
|
||||
{
|
||||
Assert.That(
|
||||
EnemyController.AllowsStateMovement(EnemyState.Chase, EnemyAttackShape.Box),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
private static Vector2 DirectionAtDegrees(float degrees)
|
||||
{
|
||||
float radians = degrees * Mathf.Deg2Rad;
|
||||
return new Vector2(Mathf.Cos(radians), Mathf.Sin(radians));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LancerAttackMotionTests
|
||||
{
|
||||
[Test]
|
||||
public void WarningReach_RetractsFromSixtyFiveToThirtyFivePercent()
|
||||
{
|
||||
Assert.That(
|
||||
LancerAttackMotion.EvaluateWarningReachFraction(0f),
|
||||
Is.EqualTo(0.65f).Within(0.0001f));
|
||||
Assert.That(
|
||||
LancerAttackMotion.EvaluateWarningReachFraction(1f),
|
||||
Is.EqualTo(0.35f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ActiveReach_ExtendsThenReturnsDuringNonDamagingTail()
|
||||
{
|
||||
Assert.That(
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(0f),
|
||||
Is.EqualTo(0.35f).Within(0.0001f));
|
||||
Assert.That(
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(0.65f),
|
||||
Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(1f),
|
||||
Is.EqualTo(0.35f).Within(0.0001f));
|
||||
Assert.That(
|
||||
LancerAttackMotion.IsDamageWindow(0.65f),
|
||||
Is.True);
|
||||
Assert.That(
|
||||
LancerAttackMotion.IsDamageWindow(0.651f),
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[TestCase(1f, 0f)]
|
||||
[TestCase(-1f, 0f)]
|
||||
[TestCase(0f, 1f)]
|
||||
[TestCase(1f, 1f)]
|
||||
public void FullReachTip_ProjectsToAttackLengthInLockedDirection(
|
||||
float directionX,
|
||||
float directionY)
|
||||
{
|
||||
Vector2 direction = new(directionX, directionY);
|
||||
Vector2 tip = LancerAttackMotion.GetTipPosition(
|
||||
Vector2.zero,
|
||||
direction,
|
||||
1.8f,
|
||||
1f);
|
||||
|
||||
Assert.That(Vector2.Dot(tip, direction.normalized), Is.EqualTo(1.8f).Within(0.0001f));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54d8919b5c8bbb34d8716c88211a8887
|
||||
@@ -0,0 +1,46 @@
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class CombatFeedbackV2EditModeTests
|
||||
{
|
||||
[Test]
|
||||
public void CycloneDefinition_PreservesNormalAndChargedRuntimeTickInputs()
|
||||
{
|
||||
ActiveArtifactDefinition definition = AssetDatabase.LoadAssetAtPath<
|
||||
ActiveArtifactDefinition>(
|
||||
"Assets/_Project/Constants/Artifacts/CycloneArtifact.asset");
|
||||
|
||||
Assert.That(definition, Is.Not.Null);
|
||||
Assert.That(definition.Effect, Is.EqualTo(ActiveArtifactEffect.Cyclone));
|
||||
Assert.That(definition.NormalDamage, Is.EqualTo(6f));
|
||||
Assert.That(definition.ChargedDamage, Is.EqualTo(8f));
|
||||
Assert.That(definition.NormalDuration, Is.EqualTo(0.36f).Within(0.0001f));
|
||||
Assert.That(definition.ChargedDuration, Is.EqualTo(0.65f).Within(0.0001f));
|
||||
Assert.That(definition.HitInterval, Is.EqualTo(0.14f).Within(0.0001f));
|
||||
Assert.That(definition.NormalRange, Is.EqualTo(0.85f).Within(0.0001f));
|
||||
Assert.That(definition.ChargedRange, Is.EqualTo(1.25f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NecroGolemPrefab_UsesTheLargePhysicalBodyContract()
|
||||
{
|
||||
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
"Assets/_Project/Prefabs/Enemies/NecroGolem.prefab");
|
||||
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f));
|
||||
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f));
|
||||
|
||||
CircleCollider2D body = prefab.GetComponent<CircleCollider2D>();
|
||||
Assert.That(body, Is.Not.Null);
|
||||
Assert.That(body.radius, Is.EqualTo(0.45f).Within(0.0001f));
|
||||
Assert.That(body.offset.y, Is.EqualTo(-0.28f).Within(0.0001f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f4f02fb53ab4b2a9c7e1d6a0e5f8b31
|
||||
@@ -0,0 +1,143 @@
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class EnemyModelTests
|
||||
{
|
||||
private GameObject owner;
|
||||
private EnemyDefinition definition;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
owner = new GameObject("Enemy Model Tests");
|
||||
definition = ScriptableObject.CreateInstance<EnemyDefinition>();
|
||||
definition.Configure(
|
||||
EnemyKind.Slime,
|
||||
EnemyAttackShape.Body,
|
||||
100f,
|
||||
4f,
|
||||
12f,
|
||||
1f,
|
||||
0.2f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
10);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
Object.DestroyImmediate(definition);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnemyModel_UsesSharedModifiersForHealthSpeedAttackAndIncomingDamage()
|
||||
{
|
||||
EnemyModel model = owner.AddComponent<EnemyModel>();
|
||||
model.ConfigureDefinition(definition);
|
||||
model.ConfigureEventMultipliers(1.1f, 1.2f, 1.3f);
|
||||
CharacterModel character = model;
|
||||
|
||||
character.AddModifier(new StatModifier(
|
||||
"max-health",
|
||||
CharacterStat.MaxHealth,
|
||||
ModifierOperation.Increased,
|
||||
0.2f));
|
||||
character.AddModifier(new StatModifier(
|
||||
"move-speed-flat",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Flat,
|
||||
0.5f));
|
||||
character.AddModifier(new StatModifier(
|
||||
"move-speed-increased",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.25f));
|
||||
character.AddModifier(new StatModifier(
|
||||
"attack-increased",
|
||||
CharacterStat.AttackDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.2f));
|
||||
character.AddModifier(new StatModifier(
|
||||
"incoming-flat",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.Flat,
|
||||
2f));
|
||||
character.AddModifier(new StatModifier(
|
||||
"incoming-increased",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.25f));
|
||||
character.AddModifier(new StatModifier(
|
||||
"incoming-more",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.More,
|
||||
0.1f));
|
||||
|
||||
Assert.That(character.MaxHealth, Is.EqualTo(132f).Within(0.001f));
|
||||
Assert.That(character.MoveSpeed, Is.EqualTo(6.625f).Within(0.001f));
|
||||
Assert.That(model.AttackDamage, Is.EqualTo(18.72f).Within(0.001f));
|
||||
Assert.That(
|
||||
model.CalculatePatternDamage(1.5f, true),
|
||||
Is.EqualTo(28.08f).Within(0.001f));
|
||||
|
||||
float modifiedIncomingDamage = character.CalculateIncomingDamage(10f);
|
||||
Assert.That(modifiedIncomingDamage, Is.EqualTo(16.5f).Within(0.001f));
|
||||
Assert.That(
|
||||
DamageCalculator.ApplyDamageTakenEffects(
|
||||
modifiedIncomingDamage,
|
||||
0f,
|
||||
false,
|
||||
0.25f,
|
||||
true),
|
||||
Is.EqualTo(20f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnprotectedSummon_KeepsHealthMultiplierWithoutEventSpeedOrDamageTuning()
|
||||
{
|
||||
owner.AddComponent<Rigidbody2D>();
|
||||
owner.AddComponent<BoxCollider2D>();
|
||||
owner.AddComponent<SpriteRenderer>();
|
||||
EnemyController enemy = owner.AddComponent<EnemyController>();
|
||||
enemy.Configure(definition, null);
|
||||
RunEventEnemyTuning tuning = RunEventEnemyTuning.Create(
|
||||
EnemyKind.Slime,
|
||||
1.5f,
|
||||
10,
|
||||
1f,
|
||||
Color.white,
|
||||
2f,
|
||||
2f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
false);
|
||||
|
||||
enemy.ConfigureSummonedEnemy(null, tuning, false);
|
||||
|
||||
Assert.That(enemy.MaximumHealth, Is.EqualTo(150f).Within(0.001f));
|
||||
Assert.That(enemy.MoveSpeed, Is.EqualTo(4f).Within(0.001f));
|
||||
Assert.That(enemy.AttackDamage, Is.EqualTo(12f).Within(0.001f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af0f3b0082644d57a9304cbd8b671d0b
|
||||
@@ -0,0 +1,54 @@
|
||||
using BumpCombat.Core;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class ExperienceSystemTests
|
||||
{
|
||||
[TestCase(1, 10)]
|
||||
[TestCase(2, 15)]
|
||||
[TestCase(3, 20)]
|
||||
[TestCase(10, 55)]
|
||||
public void RequiredExperienceForLevel_UsesWorkOrderFormula(int level, int expected)
|
||||
{
|
||||
Assert.That(ExperienceSystem.RequiredExperienceForLevel(level), Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddExperience_UsesPlayerStatsExperienceGain()
|
||||
{
|
||||
GameObject owner = new("ExperienceSystem Tests");
|
||||
try
|
||||
{
|
||||
PlayerStats stats = owner.AddComponent<PlayerStats>();
|
||||
ExperienceSystem experience = owner.AddComponent<ExperienceSystem>();
|
||||
typeof(ExperienceSystem)
|
||||
.GetMethod(
|
||||
"Awake",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.Invoke(experience, null);
|
||||
stats.AddModifier(new StatModifier(
|
||||
"experience-increased",
|
||||
CharacterStat.ExperienceGain,
|
||||
ModifierOperation.Increased,
|
||||
0.1f));
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
experience.AddExperience(1);
|
||||
}
|
||||
|
||||
Assert.That(experience.Level, Is.EqualTo(2));
|
||||
Assert.That(experience.CurrentExperience, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: edfca9956c00a0d46af3be57412d711a
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Reflection;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class FiniteArenaGeometryTests
|
||||
{
|
||||
[TearDown]
|
||||
public void Cleanup()
|
||||
{
|
||||
ArenaBounds[] bounds = Object.FindObjectsByType<ArenaBounds>(
|
||||
FindObjectsSortMode.None);
|
||||
for (int i = 0; i < bounds.Length; i++)
|
||||
{
|
||||
Object.DestroyImmediate(bounds[i].gameObject);
|
||||
}
|
||||
|
||||
SpawnDirector[] directors = Object.FindObjectsByType<SpawnDirector>(
|
||||
FindObjectsSortMode.None);
|
||||
for (int i = 0; i < directors.Length; i++)
|
||||
{
|
||||
if (directors[i] != null)
|
||||
{
|
||||
Object.DestroyImmediate(directors[i].gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
ArenaBounds.Instance?.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultGeometry_UsesFullFloorWithSmallSharedActorClearance()
|
||||
{
|
||||
GameObject root = CreateBounds();
|
||||
ArenaBounds bounds = root.GetComponent<ArenaBounds>();
|
||||
|
||||
Assert.That(bounds.BackgroundHalfExtents.x, Is.EqualTo(15f).Within(0.0001f));
|
||||
Assert.That(bounds.BackgroundHalfExtents.y, Is.EqualTo(8.4375f).Within(0.0001f));
|
||||
Assert.That(bounds.PlayableHalfExtents, Is.EqualTo(bounds.BackgroundHalfExtents));
|
||||
Assert.That(bounds.PlayerPadding, Is.EqualTo(new Vector2(0.5f, 0.625f)));
|
||||
Assert.That(bounds.PlayerClampHalfExtents.x, Is.EqualTo(14.5f).Within(0.0001f));
|
||||
Assert.That(bounds.PlayerClampHalfExtents.y, Is.EqualTo(7.8125f).Within(0.0001f));
|
||||
Assert.That(bounds.SharedActorClampHalfExtents, Is.EqualTo(bounds.PlayerClampHalfExtents));
|
||||
Assert.That(bounds.CameraCenterHalfExtents.x, Is.EqualTo(5f).Within(0.0001f));
|
||||
Assert.That(bounds.CameraCenterHalfExtents.y, Is.EqualTo(2.8125f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClampsPlayerEnemyAndCameraInsideFiniteMap()
|
||||
{
|
||||
GameObject root = CreateBounds();
|
||||
ArenaBounds bounds = root.GetComponent<ArenaBounds>();
|
||||
|
||||
Vector2 playerPosition = bounds.ClampPlayerPosition(new Vector2(99f, -99f));
|
||||
Assert.That(playerPosition.x, Is.EqualTo(14.5f).Within(0.0001f));
|
||||
Assert.That(playerPosition.y, Is.EqualTo(-7.8125f).Within(0.0001f));
|
||||
Vector2 enemyPosition = bounds.ClampEnemyPosition(new Vector2(-99f, 99f));
|
||||
Assert.That(enemyPosition.x, Is.EqualTo(-14.5f).Within(0.0001f));
|
||||
Assert.That(enemyPosition.y, Is.EqualTo(7.8125f).Within(0.0001f));
|
||||
Assert.That(
|
||||
bounds.ClampEnemyPosition(new Vector2(99f, -99f)),
|
||||
Is.EqualTo(bounds.ClampPlayerPosition(new Vector2(99f, -99f))));
|
||||
Vector2 cameraPosition = bounds.ClampCameraCenter(new Vector2(99f, -99f));
|
||||
Assert.That(cameraPosition.x, Is.EqualTo(5f).Within(0.0001f));
|
||||
Assert.That(cameraPosition.y, Is.EqualTo(-2.8125f).Within(0.0001f));
|
||||
|
||||
GameObject cameraObject = new("Arena Bounds Camera Test");
|
||||
Camera camera = cameraObject.AddComponent<Camera>();
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = 5.625f;
|
||||
camera.aspect = 16f / 9f;
|
||||
Vector2 cameraCenter = bounds.ClampCameraCenter(
|
||||
new Vector2(99f, 99f),
|
||||
bounds.GetCameraVisibleHalfExtents(camera));
|
||||
camera.transform.position = cameraCenter;
|
||||
Bounds visibleBounds = bounds.GetCameraVisibleBounds(camera);
|
||||
Assert.That(visibleBounds.min.x, Is.GreaterThanOrEqualTo(-15f));
|
||||
Assert.That(visibleBounds.max.x, Is.EqualTo(15f).Within(0.0001f));
|
||||
Assert.That(visibleBounds.min.y, Is.GreaterThanOrEqualTo(-8.4375f));
|
||||
Assert.That(visibleBounds.max.y, Is.EqualTo(8.4375f).Within(0.0001f));
|
||||
|
||||
cameraCenter = bounds.ClampCameraCenter(
|
||||
new Vector2(-99f, -99f),
|
||||
bounds.GetCameraVisibleHalfExtents(camera));
|
||||
camera.transform.position = cameraCenter;
|
||||
visibleBounds = bounds.GetCameraVisibleBounds(camera);
|
||||
Assert.That(visibleBounds.min.x, Is.EqualTo(-15f).Within(0.0001f));
|
||||
Assert.That(visibleBounds.max.x, Is.LessThanOrEqualTo(15f));
|
||||
Assert.That(visibleBounds.min.y, Is.EqualTo(-8.4375f).Within(0.0001f));
|
||||
Assert.That(visibleBounds.max.y, Is.LessThanOrEqualTo(8.4375f));
|
||||
Object.DestroyImmediate(cameraObject);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeadZone_HoldsUntilTargetCrossesEachAxis()
|
||||
{
|
||||
Assert.That(
|
||||
ArenaCameraFollow.GetDeadZoneTargetCenter(
|
||||
Vector2.zero,
|
||||
new Vector2(0.5f, 0.3f),
|
||||
new Vector2(0.5f, 0.3f)),
|
||||
Is.EqualTo(Vector2.zero));
|
||||
Vector2 movedCenter = ArenaCameraFollow.GetDeadZoneTargetCenter(
|
||||
Vector2.zero,
|
||||
new Vector2(0.7f, -0.5f),
|
||||
new Vector2(0.5f, 0.3f));
|
||||
Assert.That(movedCenter.x, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(movedCenter.y, Is.EqualTo(-0.2f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SpawnPositionsStayReachableAndClearOfPlayer()
|
||||
{
|
||||
GameObject boundsObject = CreateBounds();
|
||||
ArenaBounds bounds = boundsObject.GetComponent<ArenaBounds>();
|
||||
GameObject directorObject = new("Spawn Director Test");
|
||||
SpawnDirector director = directorObject.AddComponent<SpawnDirector>();
|
||||
|
||||
for (int i = 0; i < 64; i++)
|
||||
{
|
||||
Vector2 position = director.GetSpawnPositionForTests();
|
||||
Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True);
|
||||
Assert.That(position.magnitude, Is.GreaterThanOrEqualTo(4f));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnemyGroundAnchors_StayOnSharedPlayerBoundaryAfterEdgeAndCornerKnockback()
|
||||
{
|
||||
GameObject boundsObject = CreateBounds();
|
||||
ArenaBounds bounds = boundsObject.GetComponent<ArenaBounds>();
|
||||
EnemyController normalEnemy = CreateEnemy(
|
||||
new Vector2(0.72f, 0.72f),
|
||||
new Vector2(0.1f, -0.28f),
|
||||
1f);
|
||||
EnemyController eventEnemy = CreateEnemy(
|
||||
new Vector2(0.72f, 0.72f),
|
||||
new Vector2(-0.18f, -0.28f),
|
||||
1.25f);
|
||||
|
||||
try
|
||||
{
|
||||
MethodInfo clampMethod = typeof(EnemyController).GetMethod(
|
||||
"ClampEnemyPosition",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(clampMethod, Is.Not.Null);
|
||||
|
||||
EnemyController[] enemies = { normalEnemy, eventEnemy };
|
||||
for (int enemyIndex = 0; enemyIndex < enemies.Length; enemyIndex++)
|
||||
{
|
||||
EnemyController enemy = enemies[enemyIndex];
|
||||
Collider2D collider = enemy.GetComponent<Collider2D>();
|
||||
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
|
||||
Vector2[] knockbackDestinations =
|
||||
{
|
||||
new(100f, 100f),
|
||||
new(-100f, 100f),
|
||||
new(100f, -100f),
|
||||
new(-100f, -100f),
|
||||
};
|
||||
|
||||
for (int i = 0; i < knockbackDestinations.Length; i++)
|
||||
{
|
||||
Vector2 clamped = (Vector2)clampMethod.Invoke(
|
||||
enemy,
|
||||
new object[] { knockbackDestinations[i] });
|
||||
body.position = clamped;
|
||||
Physics2D.SyncTransforms();
|
||||
|
||||
Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Assert.That(body.position.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.0001f));
|
||||
Assert.That(body.position.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.0001f));
|
||||
Assert.That(body.position.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.0001f));
|
||||
Assert.That(body.position.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.0001f));
|
||||
Bounds colliderBounds = collider.bounds;
|
||||
// The collider can overhang the ground-anchor edge by
|
||||
// its art footprint; only the anchor defines movement.
|
||||
Assert.That(colliderBounds.size.x, Is.GreaterThan(0f));
|
||||
Assert.That(colliderBounds.size.y, Is.GreaterThan(0f));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(normalEnemy.gameObject);
|
||||
Object.DestroyImmediate(eventEnemy.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnemyGroundAnchor_PostPhysicsCorrectionRestoresSharedBoundary()
|
||||
{
|
||||
CreateBounds();
|
||||
EnemyController enemy = CreateEnemy(
|
||||
new Vector2(0.72f, 0.72f),
|
||||
new Vector2(-0.18f, -0.28f),
|
||||
1.25f);
|
||||
|
||||
try
|
||||
{
|
||||
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
|
||||
Collider2D collider = enemy.GetComponent<Collider2D>();
|
||||
body.position = new Vector2(100f, -100f);
|
||||
Physics2D.SyncTransforms();
|
||||
|
||||
MethodInfo correctionMethod = typeof(EnemyController).GetMethod(
|
||||
"KeepBodyInsideArena",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(correctionMethod, Is.Not.Null);
|
||||
correctionMethod.Invoke(enemy, null);
|
||||
Physics2D.SyncTransforms();
|
||||
|
||||
Bounds colliderBounds = collider.bounds;
|
||||
ArenaBounds bounds = ArenaBounds.Resolve();
|
||||
Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Assert.That(body.position.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.0001f));
|
||||
Assert.That(body.position.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.0001f));
|
||||
Assert.That(body.position.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.0001f));
|
||||
Assert.That(body.position.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.0001f));
|
||||
Assert.That(colliderBounds.size.x, Is.GreaterThan(0f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(enemy.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static EnemyController CreateEnemy(
|
||||
Vector2 colliderSize,
|
||||
Vector2 colliderOffset,
|
||||
float rootScale)
|
||||
{
|
||||
GameObject enemyObject = new("Boundary Test Enemy");
|
||||
Rigidbody2D body = enemyObject.AddComponent<Rigidbody2D>();
|
||||
body.bodyType = RigidbodyType2D.Kinematic;
|
||||
body.gravityScale = 0f;
|
||||
BoxCollider2D collider = enemyObject.AddComponent<BoxCollider2D>();
|
||||
collider.size = colliderSize;
|
||||
collider.offset = colliderOffset;
|
||||
enemyObject.AddComponent<SpriteRenderer>();
|
||||
enemyObject.transform.localScale = Vector3.one * rootScale;
|
||||
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
|
||||
// Unity does not invoke runtime Awake in EditMode. Populate the
|
||||
// same private references that Awake would cache so this test
|
||||
// exercises the controller's real clamp path.
|
||||
SetPrivateField(enemy, "body", body);
|
||||
SetPrivateField(enemy, "bodyCollider", collider);
|
||||
Physics2D.SyncTransforms();
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private static void SetPrivateField<T>(
|
||||
object target,
|
||||
string fieldName,
|
||||
T value)
|
||||
{
|
||||
typeof(EnemyController)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static GameObject CreateBounds()
|
||||
{
|
||||
GameObject root = new("Arena Bounds Test");
|
||||
root.AddComponent<ArenaBounds>();
|
||||
return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5f151c42dbb4b2d8bb0df41ec6ad77e
|
||||
@@ -0,0 +1,173 @@
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class GameplayConstantsConsumptionTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
[Test]
|
||||
public void ExperienceAndMissionRules_ReadChangedLevelUpSettings()
|
||||
{
|
||||
LevelUpConstants constants = GameplayConstants.Current.LevelUp;
|
||||
int originalBaseExperience = constants.BaseExperienceToNextLevel;
|
||||
int originalAdditionalExperience = constants.AdditionalExperiencePerLevel;
|
||||
float originalRampageWindow = constants.RampageWindowSeconds;
|
||||
MissionTuning[] originalMissions = constants.Missions;
|
||||
try
|
||||
{
|
||||
constants.BaseExperienceToNextLevel = 19;
|
||||
constants.AdditionalExperiencePerLevel = 8;
|
||||
MissionTuning[] changedMissions =
|
||||
(MissionTuning[])originalMissions.Clone();
|
||||
changedMissions[0].Target = 7;
|
||||
changedMissions[0].ConditionFormat = "Defeat {0} units";
|
||||
changedMissions[0].ExperienceReward = 23;
|
||||
changedMissions[10].Target = 9;
|
||||
changedMissions[10].ConditionFormat = "Within {1}s defeat {0}";
|
||||
constants.Missions = changedMissions;
|
||||
constants.RampageWindowSeconds = 4f;
|
||||
|
||||
Assert.That(
|
||||
ExperienceSystem.RequiredExperienceForLevel(3),
|
||||
Is.EqualTo(35));
|
||||
RunMissionDefinition mission = RunMissionTracker.GetDefinition(0);
|
||||
Assert.That(mission.Target, Is.EqualTo(7));
|
||||
Assert.That(mission.ExperienceReward, Is.EqualTo(23));
|
||||
Assert.That(mission.Condition, Is.EqualTo("Defeat 7 units"));
|
||||
Assert.That(RunMissionTracker.RampageKillTarget, Is.EqualTo(9));
|
||||
Assert.That(
|
||||
RunMissionTracker.GetDefinition(10).Condition,
|
||||
Is.EqualTo("Within 4s defeat 9"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
constants.BaseExperienceToNextLevel = originalBaseExperience;
|
||||
constants.AdditionalExperiencePerLevel = originalAdditionalExperience;
|
||||
constants.Missions = originalMissions;
|
||||
constants.RampageWindowSeconds = originalRampageWindow;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SpawnDensityRules_ReadChangedRunSettings()
|
||||
{
|
||||
RunConstants constants = GameplayConstants.Current.Run;
|
||||
SpawnDensityPhase[] originalPhases = constants.SpawnDensity;
|
||||
try
|
||||
{
|
||||
SpawnDensityPhase[] changedPhases =
|
||||
(SpawnDensityPhase[])originalPhases.Clone();
|
||||
changedPhases[0] = new SpawnDensityPhase(60f, 9, 8);
|
||||
constants.SpawnDensity = changedPhases;
|
||||
|
||||
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(0f), Is.EqualTo(9));
|
||||
Assert.That(SpawnDirector.TargetNormalAliveCountAt(0f), Is.EqualTo(8));
|
||||
}
|
||||
finally
|
||||
{
|
||||
constants.SpawnDensity = originalPhases;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerAndBumpRules_ReadChangedSettings()
|
||||
{
|
||||
PlayerConstants player = GameplayConstants.Current.Player;
|
||||
CombatConstants combat = GameplayConstants.Current.Combat;
|
||||
float originalSpeed = player.BaseMoveSpeed;
|
||||
float originalPositionMultiplier = combat.PositionDamageMultiplier;
|
||||
GameObject owner = new("Gameplay Constants Player Consumer Test");
|
||||
try
|
||||
{
|
||||
PlayerStats stats = owner.AddComponent<PlayerStats>();
|
||||
player.BaseMoveSpeed = 6.25f;
|
||||
combat.PositionDamageMultiplier = 1.7f;
|
||||
Assert.That(stats.MoveSpeed, Is.EqualTo(6.25f));
|
||||
Assert.That(
|
||||
BumpCombatMath.ApplySideMultiplier(10f, HitSide.Side),
|
||||
Is.EqualTo(17f).Within(0.0001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
player.BaseMoveSpeed = originalSpeed;
|
||||
combat.PositionDamageMultiplier = originalPositionMultiplier;
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArtifactGaugeRule_ReadsChangedArtifactSettings()
|
||||
{
|
||||
ArtifactConstants artifacts = GameplayConstants.Current.Artifacts;
|
||||
float originalGaugeGain = artifacts.NormalBumpGaugeGain;
|
||||
GameObject playerObject = new("Gameplay Constants Artifact Consumer Test");
|
||||
try
|
||||
{
|
||||
playerObject.AddComponent<PlayerStats>();
|
||||
playerObject.AddComponent<Rigidbody2D>();
|
||||
ActiveArtifactController controller =
|
||||
playerObject.AddComponent<ActiveArtifactController>();
|
||||
InvokePrivate(controller, "Awake");
|
||||
artifacts.NormalBumpGaugeGain = 9f;
|
||||
controller.AddNormalHitCharge();
|
||||
Assert.That(controller.CurrentGauge, Is.EqualTo(9f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
artifacts.NormalBumpGaugeGain = originalGaugeGain;
|
||||
Object.DestroyImmediate(playerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ImportedCatalog_ReferencesEveryCategoryAndPreservesRunArrays()
|
||||
{
|
||||
GameplayConstants catalog = AssetDatabase.LoadAssetAtPath<GameplayConstants>(
|
||||
"Assets/_Project/Constants/Resources/GameplayConstants.asset");
|
||||
Assert.That(catalog, Is.Not.Null);
|
||||
Assert.That(catalog.Player, Is.SameAs(AssetDatabase.LoadAssetAtPath<PlayerConstants>(
|
||||
"Assets/_Project/Constants/Player/PlayerConstants.asset")));
|
||||
Assert.That(catalog.Combat, Is.SameAs(AssetDatabase.LoadAssetAtPath<CombatConstants>(
|
||||
"Assets/_Project/Constants/Combat/CombatConstants.asset")));
|
||||
Assert.That(catalog.Enemies, Is.SameAs(AssetDatabase.LoadAssetAtPath<EnemyConstants>(
|
||||
"Assets/_Project/Constants/Enemies/EnemyConstants.asset")));
|
||||
Assert.That(catalog.Items, Is.SameAs(AssetDatabase.LoadAssetAtPath<ItemConstants>(
|
||||
"Assets/_Project/Constants/Items/ItemConstants.asset")));
|
||||
Assert.That(catalog.LevelUp, Is.SameAs(AssetDatabase.LoadAssetAtPath<LevelUpConstants>(
|
||||
"Assets/_Project/Constants/LevelUp/LevelUpConstants.asset")));
|
||||
Assert.That(catalog.Artifacts, Is.SameAs(AssetDatabase.LoadAssetAtPath<ArtifactConstants>(
|
||||
"Assets/_Project/Constants/Artifacts/ArtifactConstants.asset")));
|
||||
Assert.That(catalog.Run, Is.SameAs(AssetDatabase.LoadAssetAtPath<RunConstants>(
|
||||
"Assets/_Project/Constants/Run/RunConstants.asset")));
|
||||
Assert.That(catalog.CombatFeedback, Is.SameAs(AssetDatabase.LoadAssetAtPath<CombatFeedbackSettings>(
|
||||
"Assets/_Project/Constants/Combat/CombatFeedback.asset")));
|
||||
Assert.That(catalog.Run.MidBossKinds, Is.EqualTo(new[] {
|
||||
EnemyKind.GreatswordSkeleton,
|
||||
EnemyKind.NecroGolem,
|
||||
}));
|
||||
Assert.That(catalog.Run.ProductionEliteAttacksPerSequence, Is.EqualTo(new[] { 1, 2, 3, 2 }));
|
||||
Assert.That(catalog.Run.DevelopmentEliteEventTimes, Is.EqualTo(new[] { 36f, 72f, 108f, 144f, 180f, 216f }));
|
||||
Assert.That(catalog.Run.ProductionEliteEventTimes, Is.EqualTo(new[] { 180f, 360f, 540f, 720f }));
|
||||
}
|
||||
|
||||
private static void InvokePrivate(object target, string methodName)
|
||||
{
|
||||
MethodInfo method = target.GetType().GetMethod(methodName, NonPublicInstance);
|
||||
Assert.That(method, Is.Not.Null, methodName);
|
||||
method.Invoke(target, null);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4699af2b037e4eb78345dca6281b73cd
|
||||
@@ -0,0 +1,333 @@
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests.EditMode
|
||||
{
|
||||
public sealed class InteractionFeedbackTests
|
||||
{
|
||||
[TestCase("Slime/Slime_Idle", 46)]
|
||||
[TestCase("Bat/Bat_Idle-shadow-v2", 40)]
|
||||
[TestCase("ArmoredSkeleton/ArmoredSkeleton_Idle-shadow-v2", 39)]
|
||||
[TestCase("NecroGolem/NecroGolem_Idle-shadow-v2", 29)]
|
||||
public void CrowdControlHeadAnchor_IgnoresHiddenRendererAndTracksScaledHead(string asset, int top)
|
||||
{
|
||||
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/_Project/Art/Characters/" + asset + ".png");
|
||||
Assert.That(texture, Is.Not.Null);
|
||||
var sprite = Sprite.Create(texture, new Rect(0, 0, 100, 100), new Vector2(.5f, .5f), 32f);
|
||||
var target = new GameObject("CC Head Test");
|
||||
var renderer = target.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.enabled = false;
|
||||
foreach (float scale in new[] { 1f, 1.25f, 2f })
|
||||
{
|
||||
target.transform.position = new Vector3(3f, -2f, 0f);
|
||||
target.transform.localScale = Vector3.one * scale;
|
||||
Vector3 actual = target.transform.TransformPoint(CombatFeedback.CalculateStunMarkerLocalPosition(target.transform, renderer));
|
||||
Assert.That(actual.x, Is.EqualTo(3f).Within(.0001f));
|
||||
Assert.That(actual.y, Is.EqualTo(-2f + (50f - top) / 32f * scale + (4f + 5f * scale) / 32f).Within(.0001f));
|
||||
}
|
||||
Object.DestroyImmediate(target);
|
||||
Object.DestroyImmediate(sprite);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CrowdControlSpiral_UsesDistinctPalettesAndLoopsEightFrames()
|
||||
{
|
||||
Sprite first = CrowdControlMarkerArt.Frame(0f, false);
|
||||
Assert.That(first, Is.Not.Null);
|
||||
Assert.That(first.rect.size, Is.EqualTo(new Vector2(32f, 24f)));
|
||||
Assert.That(first.texture.filterMode, Is.EqualTo(FilterMode.Point));
|
||||
Assert.That(CrowdControlMarkerArt.Frame(.07f, false).rect.x, Is.EqualTo(32f));
|
||||
Assert.That(CrowdControlMarkerArt.Frame(.481f, false), Is.SameAs(first));
|
||||
Assert.That(CrowdControlMarkerArt.Frame(0f, true).texture, Is.Not.SameAs(first.texture));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StrongDashLaunchResult_IsDistinctFromStrongDashIntent()
|
||||
{
|
||||
CombatHitResult resisted = new(
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
HitSide.Front,
|
||||
1f,
|
||||
Vector2.right,
|
||||
1f,
|
||||
Vector2.zero,
|
||||
false,
|
||||
true,
|
||||
DamageTag.Collision,
|
||||
"Dash",
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
CombatHitResult launched = new(
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
HitSide.Front,
|
||||
1f,
|
||||
Vector2.right,
|
||||
1f,
|
||||
Vector2.zero,
|
||||
false,
|
||||
true,
|
||||
DamageTag.Collision,
|
||||
"Dash",
|
||||
true,
|
||||
true,
|
||||
true);
|
||||
|
||||
Assert.That(resisted.IsStrongDash, Is.True);
|
||||
Assert.That(resisted.LaunchSucceeded, Is.False);
|
||||
Assert.That(launched.IsStrongDash, Is.True);
|
||||
Assert.That(launched.LaunchSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[TestCase(0.09f, false)]
|
||||
[TestCase(0.1f, true)]
|
||||
[TestCase(0.5f, true)]
|
||||
public void PulseGatherDistance_UsesMinimumStreakThreshold(float distance, bool expectedStreak)
|
||||
{
|
||||
Assert.That(CombatFeedback.ShouldShowPulseStreak(distance), Is.EqualTo(expectedStreak));
|
||||
}
|
||||
|
||||
[TestCase(0.49f, false)]
|
||||
[TestCase(0.5f, true)]
|
||||
public void KnockbackDistance_UsesMinimumDustThreshold(float distance, bool expectedDust)
|
||||
{
|
||||
Assert.That(CombatFeedback.ShouldShowKnockbackDust(distance), Is.EqualTo(expectedDust));
|
||||
}
|
||||
|
||||
[TestCase(true, false, true)]
|
||||
[TestCase(true, true, false)]
|
||||
[TestCase(false, false, false)]
|
||||
public void LaunchResult_SeparatesResistFromDeath(
|
||||
bool canBeLaunched,
|
||||
bool isDead,
|
||||
bool expectedLaunch)
|
||||
{
|
||||
Assert.That(
|
||||
BumpCombatResolver.CanLaunchAfterHit(canBeLaunched, isDead),
|
||||
Is.EqualTo(expectedLaunch));
|
||||
Assert.That(
|
||||
BumpCombatResolver.ShouldShowLaunchResist(canBeLaunched),
|
||||
Is.EqualTo(!canBeLaunched));
|
||||
}
|
||||
|
||||
[TestCase(false, true, false, true)]
|
||||
[TestCase(true, true, false, false)]
|
||||
[TestCase(false, false, false, false)]
|
||||
[TestCase(false, true, true, false)]
|
||||
public void LineTransientRecycle_RejectsPendingOrDuplicateEntries(
|
||||
bool destroyPending,
|
||||
bool activeRegistrationRemoved,
|
||||
bool alreadyPooled,
|
||||
bool expected)
|
||||
{
|
||||
Assert.That(
|
||||
CombatFeedback.CanRecycleLineTransient(
|
||||
destroyPending,
|
||||
activeRegistrationRemoved,
|
||||
alreadyPooled),
|
||||
Is.EqualTo(expected));
|
||||
Assert.That(
|
||||
CombatFeedback.MaxConcurrentHeavyEffects,
|
||||
Is.EqualTo(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WorldStreakRenderer_ResetsRingLoopState()
|
||||
{
|
||||
GameObject host = new("Line Renderer State Test");
|
||||
LineRenderer line = host.AddComponent<LineRenderer>();
|
||||
line.loop = true;
|
||||
line.useWorldSpace = false;
|
||||
|
||||
CombatFeedback.ConfigureWorldStreakRenderer(
|
||||
line,
|
||||
null,
|
||||
Color.white,
|
||||
0.04f,
|
||||
Vector2.zero,
|
||||
Vector2.right);
|
||||
|
||||
Assert.That(line.loop, Is.False);
|
||||
Assert.That(line.useWorldSpace, Is.True);
|
||||
Assert.That(line.positionCount, Is.EqualTo(2));
|
||||
Object.DestroyImmediate(host);
|
||||
}
|
||||
|
||||
[TestCase(false, false, true, true)]
|
||||
[TestCase(false, true, true, false)]
|
||||
[TestCase(false, false, false, false)]
|
||||
[TestCase(true, false, true, false)]
|
||||
public void LineTransientAcquire_RejectsPendingOrInvalidEntries(
|
||||
bool unityNull,
|
||||
bool destroyPending,
|
||||
bool hasLineRenderer,
|
||||
bool expected)
|
||||
{
|
||||
Assert.That(
|
||||
CombatFeedback.CanAcquireLineTransient(
|
||||
unityNull,
|
||||
destroyPending,
|
||||
hasLineRenderer),
|
||||
Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StunMarkerAnchor_FallbackKeepsClearanceAndScalesWithCharacter()
|
||||
{
|
||||
GameObject target = new("Stun Marker Anchor Test");
|
||||
SpriteRenderer targetRenderer = target.AddComponent<SpriteRenderer>();
|
||||
Sprite sprite = Sprite.Create(
|
||||
Texture2D.whiteTexture,
|
||||
new Rect(0f, 0f, 1f, 1f),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
32f);
|
||||
targetRenderer.sprite = sprite;
|
||||
Vector3 runeLocalScale = new(0.11f, 0.035f, 1f);
|
||||
|
||||
foreach (float targetScale in new[] { 0.8f, 1f, 1.25f, 1.6f, 2f, 2.5f, 3.5f })
|
||||
{
|
||||
Vector3 expectedWorldScale = Vector3.Scale(new Vector3(.5f * targetScale, .5f * targetScale, 1f), runeLocalScale);
|
||||
target.transform.localScale = Vector3.one * targetScale;
|
||||
Vector3 localAnchor = CombatFeedback.CalculateStunMarkerLocalPosition(
|
||||
target.transform,
|
||||
targetRenderer);
|
||||
Vector3 worldAnchor = target.transform.TransformPoint(localAnchor);
|
||||
Assert.That(worldAnchor.x, Is.EqualTo(target.transform.position.x).Within(0.0001f));
|
||||
Assert.That(
|
||||
worldAnchor.y,
|
||||
Is.EqualTo(targetRenderer.bounds.max.y + (4f + 5f * targetScale) / 32f).Within(0.0001f));
|
||||
|
||||
Vector3 baseLocalScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(
|
||||
target.transform.lossyScale);
|
||||
Vector3 worldScale = Vector3.Scale(
|
||||
target.transform.lossyScale,
|
||||
Vector3.Scale(baseLocalScale, runeLocalScale));
|
||||
Assert.That(worldScale.x, Is.EqualTo(expectedWorldScale.x).Within(0.0001f));
|
||||
Assert.That(worldScale.y, Is.EqualTo(expectedWorldScale.y).Within(0.0001f));
|
||||
}
|
||||
|
||||
Object.DestroyImmediate(sprite);
|
||||
Object.DestroyImmediate(target);
|
||||
}
|
||||
|
||||
[TestCase(485)]
|
||||
[TestCase(1000)]
|
||||
[TestCase(1515)]
|
||||
public void StunMarkerSortingOrder_StaysAheadOfTarget(int targetSortingOrder)
|
||||
{
|
||||
Assert.That(
|
||||
CombatFeedback.CalculateStunMarkerSortingOrder(targetSortingOrder),
|
||||
Is.GreaterThan(targetSortingOrder));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BumpImpactGrades_UseHitSideAndFixedFrameTiming()
|
||||
{
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactResourcePath(HitSide.Front),
|
||||
Is.EqualTo("Combat/BumpImpact-Weak-v1"));
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactResourcePath(HitSide.Side),
|
||||
Is.EqualTo("Combat/BumpImpact-Medium-v1"));
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactResourcePath(HitSide.Back),
|
||||
Is.EqualTo("Combat/BumpImpact-Strong-v1"));
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactFrameDuration(0),
|
||||
Is.EqualTo(0.03f));
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactFrameDuration(1),
|
||||
Is.EqualTo(0.04f));
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactFrameDuration(2),
|
||||
Is.EqualTo(0.04f));
|
||||
Assert.That(
|
||||
CombatFeedback.GetBumpImpactFrameDuration(3),
|
||||
Is.EqualTo(0.03f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BumpImpactResources_ContainFourCenteredWhitePixelFrames()
|
||||
{
|
||||
foreach (HitSide side in System.Enum.GetValues(typeof(HitSide)))
|
||||
{
|
||||
Sprite[] frames = Resources.LoadAll<Sprite>(
|
||||
CombatFeedback.GetBumpImpactResourcePath(side));
|
||||
System.Array.Sort(
|
||||
frames,
|
||||
(left, right) => System.String.CompareOrdinal(left.name, right.name));
|
||||
|
||||
Assert.That(frames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount));
|
||||
for (int i = 0; i < frames.Length; i++)
|
||||
{
|
||||
Assert.That(frames[i].name, Does.EndWith($"_{i}"));
|
||||
Assert.That(frames[i].rect.width, Is.EqualTo(64f));
|
||||
Assert.That(frames[i].rect.height, Is.EqualTo(64f));
|
||||
Assert.That(frames[i].pixelsPerUnit, Is.EqualTo(32f));
|
||||
Assert.That(frames[i].pivot.x, Is.EqualTo(32f));
|
||||
Assert.That(frames[i].pivot.y, Is.EqualTo(32f));
|
||||
Assert.That(frames[i].texture, Is.Not.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SwordsmanController_WiresCombatStatesToExpectedClipLengths()
|
||||
{
|
||||
const string controllerPath =
|
||||
"Assets/_Project/Art/Characters/Swordsman/Animations/Swordsman.controller";
|
||||
AnimatorController controller =
|
||||
AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
Assert.That(controller, Is.Not.Null);
|
||||
|
||||
AnimatorState bumpAttack = FindState(controller, "BumpAttack");
|
||||
AnimatorState artifactUse = FindState(controller, "ArtifactUse");
|
||||
Assert.That(bumpAttack, Is.Not.Null);
|
||||
Assert.That(artifactUse, Is.Not.Null);
|
||||
Assert.That((bumpAttack.motion as AnimationClip).length, Is.EqualTo(0.24f).Within(0.0001f));
|
||||
Assert.That((artifactUse.motion as AnimationClip).length, Is.EqualTo(0.4f).Within(0.0001f));
|
||||
Assert.That(bumpAttack.transitions, Has.Length.EqualTo(2));
|
||||
Assert.That(artifactUse.transitions, Has.Length.EqualTo(2));
|
||||
foreach (AnimatorStateTransition transition in bumpAttack.transitions)
|
||||
{
|
||||
Assert.That(transition.hasExitTime, Is.True);
|
||||
Assert.That(transition.exitTime, Is.EqualTo(1f));
|
||||
}
|
||||
foreach (AnimatorStateTransition transition in artifactUse.transitions)
|
||||
{
|
||||
Assert.That(transition.hasExitTime, Is.True);
|
||||
Assert.That(transition.exitTime, Is.EqualTo(1f));
|
||||
}
|
||||
|
||||
foreach (AnimatorControllerParameter parameter in controller.parameters)
|
||||
{
|
||||
Assert.That(parameter.name, Is.Not.EqualTo("BumpAttack"));
|
||||
Assert.That(parameter.name, Is.Not.EqualTo("ArtifactUse"));
|
||||
}
|
||||
}
|
||||
|
||||
private static AnimatorState FindState(
|
||||
AnimatorController controller,
|
||||
string stateName)
|
||||
{
|
||||
foreach (ChildAnimatorState child in controller.layers[0].stateMachine.states)
|
||||
{
|
||||
if (child.state.name == stateName)
|
||||
{
|
||||
return child.state;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f5ce9a33a0c4d53b0b6d5a0f8a41d2e
|
||||
timeCreated: 1788100000
|
||||
@@ -0,0 +1,267 @@
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class PlayerStatsTests
|
||||
{
|
||||
private GameObject owner;
|
||||
private PlayerStats stats;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
owner = new GameObject("PlayerStats Tests");
|
||||
stats = owner.AddComponent<PlayerStats>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Evaluate_AppliesFlatThenSummedIncreasedThenEachMore()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"flat",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Flat,
|
||||
10f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"increased-a",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.1f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"increased-b",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.2f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"more-a",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.More,
|
||||
0.1f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"more-b",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.More,
|
||||
0.2f));
|
||||
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.CollisionDamage, 100f),
|
||||
Is.EqualTo(188.76f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculateDamage_CollisionModifierDoesNotAffectSpell()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"collision-increased",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.5f));
|
||||
|
||||
Assert.That(
|
||||
stats.CalculateDamage(10f, DamageTag.Collision),
|
||||
Is.EqualTo(15f).Within(0.001f));
|
||||
Assert.That(
|
||||
stats.CalculateDamage(10f, DamageTag.Spell),
|
||||
Is.EqualTo(10f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculateDamage_AppliesSharedAttackDamageBeforeCollisionDamage()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"shared-attack-increased",
|
||||
CharacterStat.AttackDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.1f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"collision-increased",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.5f));
|
||||
|
||||
Assert.That(
|
||||
stats.CalculateDamage(10f, DamageTag.Collision),
|
||||
Is.EqualTo(16.5f).Within(0.001f));
|
||||
Assert.That(
|
||||
stats.CalculateDamage(10f, DamageTag.Spell),
|
||||
Is.EqualTo(11f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculateDamage_PreservesFractionsUntilTheFinalHit()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"damage-flat",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Flat,
|
||||
0.8f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"damage-increased",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.25f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"damage-more",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.More,
|
||||
0.2f));
|
||||
|
||||
float calculatedDamage = stats.CalculateDamage(10f, DamageTag.Collision);
|
||||
Assert.That(calculatedDamage, Is.EqualTo(16.2f).Within(0.001f));
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.CollisionDamage, 10f),
|
||||
Is.EqualTo(calculatedDamage).Within(0.001f),
|
||||
"Damage stat evaluation and the public damage API must share the same calculation.");
|
||||
Assert.That(DamageCalculator.FinalizeDamage(calculatedDamage), Is.EqualTo(16f));
|
||||
Assert.That(
|
||||
stats.CalculateDamage(10.9f, DamageTag.Spell),
|
||||
Is.EqualTo(10.9f).Within(0.001f),
|
||||
"Spell damage has no collision modifiers and keeps its intermediate fraction.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculateIncomingDamage_PreservesFractionsUntilThePlayerHit()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"incoming-flat",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.Flat,
|
||||
0.8f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"incoming-increased",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.25f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"incoming-more",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.More,
|
||||
0.2f));
|
||||
|
||||
float incomingDamage = stats.CalculateIncomingDamage(10f);
|
||||
Assert.That(incomingDamage, Is.EqualTo(16.2f).Within(0.001f));
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.IncomingDamage, 10f),
|
||||
Is.EqualTo(incomingDamage).Within(0.001f));
|
||||
Assert.That(DamageCalculator.FinalizeDamage(incomingDamage), Is.EqualTo(16f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculateDamage_PreservesFractionsAcrossMoreStacks()
|
||||
{
|
||||
StatModifier more = new(
|
||||
"damage-more-stacked",
|
||||
CharacterStat.CollisionDamage,
|
||||
ModifierOperation.More,
|
||||
0.2f,
|
||||
3);
|
||||
stats.AddModifier(more);
|
||||
stats.AddModifier(more);
|
||||
stats.AddModifier(more);
|
||||
|
||||
float calculatedDamage = stats.CalculateDamage(10f, DamageTag.Collision);
|
||||
Assert.That(calculatedDamage, Is.EqualTo(17.28f).Within(0.001f));
|
||||
Assert.That(
|
||||
DamageCalculator.FinalizeDamage(calculatedDamage),
|
||||
Is.EqualTo(17),
|
||||
"10 × 1.2 × 1.2 × 1.2 stays fractional until the final hit.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Evaluate_IncomingDamageMoreModifierReducesReceivedDamage()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"artifact.pulse.damage-reduction",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.More,
|
||||
-0.35f));
|
||||
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.IncomingDamage, 20f),
|
||||
Is.EqualTo(13f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddModifier_StopsAtMaxStacksAndReportsCurrentStacks()
|
||||
{
|
||||
StatModifier modifier = new(
|
||||
"level-up.move-speed",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.05f,
|
||||
2);
|
||||
|
||||
Assert.That(stats.AddModifier(modifier), Is.True);
|
||||
Assert.That(stats.AddModifier(modifier), Is.True);
|
||||
Assert.That(stats.AddModifier(modifier), Is.False);
|
||||
Assert.That(
|
||||
stats.GetStackCount("level-up.move-speed", CharacterStat.MoveSpeed),
|
||||
Is.EqualTo(2));
|
||||
|
||||
AppliedStatModifier applied = stats.GetAppliedModifiers()[0];
|
||||
Assert.That(applied.CurrentStacks, Is.EqualTo(2));
|
||||
Assert.That(applied.MaxStacks, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveModifiersFromSource_RemovesEveryStackFromThatSource()
|
||||
{
|
||||
StatModifier modifier = new(
|
||||
"temporary-buff",
|
||||
CharacterStat.ArtifactGaugeGain,
|
||||
ModifierOperation.Increased,
|
||||
0.1f,
|
||||
3);
|
||||
stats.AddModifier(modifier);
|
||||
stats.AddModifier(modifier);
|
||||
|
||||
Assert.That(
|
||||
stats.RemoveModifiersFromSource("temporary-buff"),
|
||||
Is.EqualTo(2));
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"temporary-buff",
|
||||
CharacterStat.ArtifactGaugeGain),
|
||||
Is.Zero);
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.ArtifactGaugeGain, 5f),
|
||||
Is.EqualTo(5f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BaseProperties_UseConfiguredDefaultsAndModifiers()
|
||||
{
|
||||
stats.AddModifier(new StatModifier(
|
||||
"level-up.max-health",
|
||||
CharacterStat.MaxHealth,
|
||||
ModifierOperation.Flat,
|
||||
10f));
|
||||
stats.AddModifier(new StatModifier(
|
||||
"level-up.move-speed",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.05f));
|
||||
|
||||
Assert.That(stats.MaxHealth, Is.EqualTo(110f).Within(0.001f));
|
||||
Assert.That(stats.MoveSpeed, Is.EqualTo(3.15f).Within(0.001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CharacterStat_KeepsExistingSerializedValuesAndAppendsAttackDamage()
|
||||
{
|
||||
Assert.That((int)CharacterStat.CollisionDamage, Is.EqualTo(0));
|
||||
Assert.That((int)CharacterStat.IncomingDamage, Is.EqualTo(8));
|
||||
Assert.That((int)CharacterStat.AttackDamage, Is.EqualTo(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c70dd0752514ca2938de65fa7ba2953
|
||||
@@ -0,0 +1,558 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class StageRosterDefinitionTests
|
||||
{
|
||||
private static readonly RosterSpec[] AmbientRoster =
|
||||
{
|
||||
new("Bat", EnemyKind.Bat, EnemyRole.Crowd),
|
||||
new("Slime", EnemyKind.Slime, EnemyRole.Crowd),
|
||||
new("Skeleton", EnemyKind.Skeleton, EnemyRole.Normal),
|
||||
new("Necrofire", EnemyKind.Necrofire, EnemyRole.Normal),
|
||||
new("SkeletonArcher", EnemyKind.SkeletonArcher, EnemyRole.Normal),
|
||||
};
|
||||
|
||||
private static readonly RosterSpec[] EventRoster =
|
||||
{
|
||||
new("ArmoredSkeleton", EnemyKind.ArmoredSkeleton, EnemyRole.Normal),
|
||||
new("Werewolf", EnemyKind.Werewolf, EnemyRole.Normal),
|
||||
new("Werebear", EnemyKind.Werebear, EnemyRole.Normal),
|
||||
new("GreatswordSkeleton", EnemyKind.GreatswordSkeleton, EnemyRole.Normal),
|
||||
new("NecroGolem", EnemyKind.NecroGolem, EnemyRole.Normal),
|
||||
new("Necromancer", EnemyKind.Necromancer, EnemyRole.Normal),
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void RequestedStageDefinitions_UseTheFiveAmbientRolesAndEventRoster()
|
||||
{
|
||||
foreach (RosterSpec spec in AmbientRoster)
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition(spec.Name);
|
||||
Assert.That(definition.Kind, Is.EqualTo(spec.Kind), spec.Name);
|
||||
Assert.That(definition.Role, Is.EqualTo(spec.Role), spec.Name);
|
||||
Assert.That(definition.MaxHealth, Is.GreaterThan(0f), spec.Name);
|
||||
Assert.That(definition.MoveSpeed, Is.GreaterThan(0f), spec.Name);
|
||||
Assert.That(definition.ExperienceValue, Is.GreaterThanOrEqualTo(0), spec.Name);
|
||||
}
|
||||
|
||||
foreach (RosterSpec spec in EventRoster)
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition(spec.Name);
|
||||
Assert.That(definition.Kind, Is.EqualTo(spec.Kind), spec.Name);
|
||||
Assert.That(definition.Role, Is.EqualTo(spec.Role), spec.Name);
|
||||
Assert.That(definition.MaxHealth, Is.GreaterThan(0f), spec.Name);
|
||||
Assert.That(definition.MoveSpeed, Is.GreaterThan(0f), spec.Name);
|
||||
Assert.That(definition.AttackPatternCount, Is.GreaterThan(0), spec.Name);
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
LoadDefinition("Necrofire").IsRanged,
|
||||
Is.True,
|
||||
"Necrofire must remain a normal ranged enemy.");
|
||||
Assert.That(
|
||||
LoadDefinition("SkeletonArcher").IsRanged,
|
||||
Is.True,
|
||||
"Skeleton Archer must remain a normal ranged enemy.");
|
||||
Assert.That(
|
||||
LoadDefinition("Necromancer").IsRanged,
|
||||
Is.True,
|
||||
"Necromancer must use ranged attack behavior.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StagePrefabs_UseRequestedCollisionRolesAndKeepLegacyLancer()
|
||||
{
|
||||
foreach (RosterSpec spec in AmbientRoster)
|
||||
{
|
||||
GameObject prefab = LoadPrefab(spec.Name);
|
||||
AssertVisualMaterial(prefab, spec.Name);
|
||||
Collider2D collider = prefab.GetComponent<Collider2D>();
|
||||
Assert.That(collider, Is.Not.Null, spec.Name);
|
||||
Assert.That(collider.isTrigger, Is.EqualTo(spec.Role == EnemyRole.Crowd), spec.Name);
|
||||
Assert.That(prefab.transform.localScale.x, Is.EqualTo(spec.Role == EnemyRole.Crowd ? 1f : 1.25f).Within(0.0001f), spec.Name);
|
||||
Assert.That(prefab.transform.localScale.y, Is.EqualTo(spec.Role == EnemyRole.Crowd ? 1f : 1.25f).Within(0.0001f), spec.Name);
|
||||
}
|
||||
|
||||
foreach (RosterSpec spec in EventRoster)
|
||||
{
|
||||
GameObject prefab = LoadPrefab(spec.Name);
|
||||
AssertVisualMaterial(prefab, spec.Name);
|
||||
Assert.That(prefab.GetComponent<Collider2D>(), Is.Not.Null, spec.Name);
|
||||
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f).Within(0.0001f), spec.Name);
|
||||
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f).Within(0.0001f), spec.Name);
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
LoadDefinition("Lancer"),
|
||||
Is.Not.Null,
|
||||
"The legacy Lancer definition must remain available for its motion regressions.");
|
||||
Assert.That(
|
||||
AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
"Assets/_Project/Prefabs/Enemies/Lancer.prefab"),
|
||||
Is.Not.Null,
|
||||
"The legacy Lancer prefab must remain available for its motion regressions.");
|
||||
Assert.That(
|
||||
AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
"Assets/_Project/Prefabs/Enemies/Warlock.prefab"),
|
||||
Is.Not.Null,
|
||||
"The legacy Warlock prefab must remain available for status regressions.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerPrefab_UsesTheSameEnlargedVisualAndColliderScale()
|
||||
{
|
||||
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
"Assets/_Project/Prefabs/Player/Player.prefab");
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f).Within(0.0001f));
|
||||
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f).Within(0.0001f));
|
||||
Assert.That(prefab.transform.localScale.z, Is.EqualTo(1f).Within(0.0001f));
|
||||
|
||||
CircleCollider2D collider = prefab.GetComponent<CircleCollider2D>();
|
||||
Assert.That(collider, Is.Not.Null);
|
||||
Assert.That(
|
||||
collider.radius * prefab.transform.localScale.x,
|
||||
Is.EqualTo(0.275f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SkeletonAttack_UsesVisibleSwordArcAndLocalDirectionalGeometry()
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition("Skeleton");
|
||||
EnemyAttackPattern pattern = definition.GetAttackPattern(0);
|
||||
|
||||
Assert.That(pattern.WarningDuration, Is.EqualTo(0.7f).Within(0.0001f));
|
||||
Assert.That(pattern.ActiveDuration, Is.EqualTo(0.15f).Within(0.0001f));
|
||||
Assert.That(pattern.AnimationLeadTime, Is.EqualTo(0.375f).Within(0.0001f));
|
||||
Assert.That(pattern.DirectionMode, Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot));
|
||||
Assert.That(pattern.GeometryScaleMode, Is.EqualTo(EnemyAttackGeometryScaleMode.RootScale));
|
||||
Assert.That(pattern.AttackRange, Is.EqualTo(0.85f).Within(0.0001f));
|
||||
Assert.That(pattern.AttackLength, Is.EqualTo(0.8f).Within(0.0001f));
|
||||
Assert.That(pattern.AttackWidth, Is.EqualTo(0.6f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RootWeaponPatterns_UseLocalScaleAndSourceContactWindows()
|
||||
{
|
||||
AssertHorizontalRootPattern("Skeleton", 0, 0.15f, 0.375f);
|
||||
AssertHorizontalRootPattern("ArmoredSkeleton", 0, 0.25f, 0.5f);
|
||||
AssertHorizontalRootPattern("ArmoredSkeleton", 1, 0.35f, 0.5f);
|
||||
AssertHorizontalRootPattern("GreatswordSkeleton", 0, 0.22f, 0.625f);
|
||||
AssertHorizontalRootPattern("GreatswordSkeleton", 1, 0.35f, 0.75f);
|
||||
AssertHorizontalRootPattern("GreatswordSkeleton", 2, 0.25f, 0.5f);
|
||||
AssertHorizontalRootPattern("Werewolf", 0, 0.25f, 0.625f);
|
||||
AssertHorizontalRootPattern("Werewolf", 1, 0.625f, 0.875f);
|
||||
AssertHorizontalRootPattern("Werebear", 0, 0.25f, 0.625f);
|
||||
AssertHorizontalRootPattern("Werebear", 1, 0.875f, 0.5f);
|
||||
AssertHorizontalRootPattern("Werebear", 2, 0.375f, 0.625f);
|
||||
AssertHorizontalRootPattern("Necromancer", 0, 0.375f, 0.625f);
|
||||
|
||||
EnemyAttackPattern bearSecond =
|
||||
LoadDefinition("Werebear").GetAttackPattern(1);
|
||||
Assert.That(bearSecond.ContactPhaseCount, Is.EqualTo(2));
|
||||
Assert.That(bearSecond.IsContactWindowActive(0f, 0.875f), Is.True);
|
||||
Assert.That(bearSecond.IsContactWindowActive(0.3f, 0.875f), Is.False);
|
||||
Assert.That(bearSecond.IsContactWindowActive(0.625f, 0.875f), Is.True);
|
||||
Assert.That(bearSecond.IsContactWindowActive(0.7f, 0.875f), Is.True);
|
||||
Assert.That(bearSecond.IsContactWindowActive(0.875f, 0.875f), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NecroGolemRootGroundContracts_StayInsideMeasuredContactFootprint()
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition("NecroGolem");
|
||||
EnemyAttackPattern first = definition.GetAttackPattern(0);
|
||||
EnemyAttackPattern spikes = definition.GetAttackPattern(1);
|
||||
EnemyAttackPattern flame = definition.GetAttackPattern(2);
|
||||
|
||||
AssertRootGroundPattern(first, 0.75f, 0.6f, new Vector2(0f, -0.15f), 0.75f);
|
||||
AssertRootGroundPattern(spikes, 1.1f, 0.6f, new Vector2(0f, -0.2f), 0.75f);
|
||||
AssertRootGroundPattern(flame, 1.05f, 0.55f, new Vector2(0.05f, -0.05f), 0.625f);
|
||||
Assert.That(first.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
Assert.That(spikes.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
Assert.That(flame.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
|
||||
Vector2 midOrigin = EnemyAttack.GetAttackOrigin(
|
||||
Vector2.zero,
|
||||
Vector2.right,
|
||||
flame.AttackOriginOffset * 2f,
|
||||
flame.DirectionMode);
|
||||
Assert.That(midOrigin, Is.EqualTo(new Vector2(0.1f, -0.1f)));
|
||||
Assert.That(flame.AttackLength * 2f, Is.EqualTo(2.1f).Within(0.0001f));
|
||||
Assert.That(flame.AttackWidth * 2f, Is.EqualTo(1.1f).Within(0.0001f));
|
||||
Assert.That(flame.IsContactWindowActive(0.25f, 0.25f), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventAndSummonedTuning_MultipliesTheEnlargedPrefabScale()
|
||||
{
|
||||
RunEventEnemyTuning tuning = RunEventEnemyTuning.Create(
|
||||
EnemyKind.Skeleton,
|
||||
1f,
|
||||
0,
|
||||
1.6f,
|
||||
Color.white,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
true);
|
||||
|
||||
GameObject eventObject = Object.Instantiate(LoadPrefab("Skeleton"));
|
||||
GameObject summonObject = Object.Instantiate(LoadPrefab("Skeleton"));
|
||||
try
|
||||
{
|
||||
eventObject.GetComponent<EnemyController>().ConfigureRunEventEnemy(
|
||||
RunTimedEvent.Elite,
|
||||
tuning);
|
||||
summonObject.GetComponent<EnemyController>().ConfigureSummonedEnemy(
|
||||
null,
|
||||
tuning,
|
||||
true);
|
||||
|
||||
Assert.That(eventObject.transform.localScale.x, Is.EqualTo(2f).Within(0.0001f));
|
||||
Assert.That(summonObject.transform.localScale.x, Is.EqualTo(2f).Within(0.0001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(eventObject);
|
||||
Object.DestroyImmediate(summonObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StagePrefabs_ImportWithFrozenRootRotationAndExpectedPhysicsDefaults()
|
||||
{
|
||||
string[] prefabNames =
|
||||
{
|
||||
"Bat",
|
||||
"Slime",
|
||||
"Skeleton",
|
||||
"Necrofire",
|
||||
"SkeletonArcher",
|
||||
"ArmoredSkeleton",
|
||||
"Werewolf",
|
||||
"Werebear",
|
||||
"GreatswordSkeleton",
|
||||
"NecroGolem",
|
||||
"Necromancer",
|
||||
};
|
||||
|
||||
foreach (string name in prefabNames)
|
||||
{
|
||||
Rigidbody2D body = LoadPrefab(name).GetComponent<Rigidbody2D>();
|
||||
Assert.That(body, Is.Not.Null, name);
|
||||
Assert.That(
|
||||
body.constraints & RigidbodyConstraints2D.FreezeRotation,
|
||||
Is.EqualTo(RigidbodyConstraints2D.FreezeRotation),
|
||||
$"{name} must keep its root upright after prefab import.");
|
||||
Assert.That(body.bodyType, Is.EqualTo(RigidbodyType2D.Dynamic), name);
|
||||
Assert.That(body.simulated, Is.True, name);
|
||||
Assert.That(body.gravityScale, Is.EqualTo(0f), name);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MidBossDefinitions_ExposeThreeDistinctAttack01To03Patterns()
|
||||
{
|
||||
AssertMidBossPatterns("GreatswordSkeleton");
|
||||
AssertMidBossPatterns("NecroGolem");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NecromancerDefinition_ExposesThreeCombatPatternsAndTwoSummonTimers()
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition("Necromancer");
|
||||
Assert.That(definition.AttackPatternCount, Is.EqualTo(3));
|
||||
|
||||
HashSet<string> animationStates = new();
|
||||
int targetCirclePatterns = 0;
|
||||
for (int i = 0; i < definition.AttackPatternCount; i++)
|
||||
{
|
||||
EnemyAttackPattern pattern = definition.GetAttackPattern(i);
|
||||
Assert.That(pattern.AnimationState, Is.Not.Null.And.Not.Empty, $"pattern {i}");
|
||||
animationStates.Add(pattern.AnimationState);
|
||||
Assert.That(pattern.WarningDuration, Is.GreaterThan(0f), $"pattern {i}");
|
||||
Assert.That(pattern.ActiveDuration, Is.GreaterThan(0f), $"pattern {i}");
|
||||
Assert.That(pattern.RecoveryDuration, Is.GreaterThan(0f), $"pattern {i}");
|
||||
if (pattern.AttackShape == EnemyAttackShape.TargetCircle)
|
||||
{
|
||||
targetCirclePatterns++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
targetCirclePatterns,
|
||||
Is.GreaterThanOrEqualTo(2),
|
||||
"The single and multi AOE entries must both be represented.");
|
||||
Assert.That(
|
||||
animationStates.Count,
|
||||
Is.GreaterThanOrEqualTo(2),
|
||||
"The melee and AOE behaviors must expose at least two animation states; the two AOE variants may share Attack02.");
|
||||
|
||||
NecromancerBossController boss =
|
||||
LoadPrefab("Necromancer").GetComponent<NecromancerBossController>();
|
||||
Assert.That(boss, Is.Not.Null);
|
||||
EnemyConstants enemyTuning = GameplayConstants.Current.Enemies;
|
||||
Assert.That(
|
||||
enemyTuning.GeneralSummonCooldown,
|
||||
Is.GreaterThanOrEqualTo(0.5f));
|
||||
Assert.That(
|
||||
enemyTuning.CrowdSummonCooldown,
|
||||
Is.GreaterThanOrEqualTo(0.5f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NecrofireDefinition_UsesLongWarningAndShortFixedRayWindow()
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition("Necrofire");
|
||||
EnemyAttackPattern pattern = definition.GetAttackPattern(0);
|
||||
|
||||
Assert.That(definition.WarningDuration, Is.EqualTo(1.5f).Within(0.0001f));
|
||||
Assert.That(definition.ActiveDuration, Is.EqualTo(0.375f).Within(0.0001f));
|
||||
Assert.That(definition.RecoveryDuration, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(definition.AttackAnimationLeadTime, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(definition.ProjectileSpeed, Is.Zero);
|
||||
Assert.That(definition.ProjectileRadius, Is.Zero);
|
||||
Assert.That(pattern.WarningDuration, Is.EqualTo(1.5f).Within(0.0001f));
|
||||
Assert.That(pattern.ActiveDuration, Is.EqualTo(0.375f).Within(0.0001f));
|
||||
Assert.That(pattern.RecoveryDuration, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(pattern.AnimationLeadTime, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box));
|
||||
Assert.That(pattern.AttackLength, Is.EqualTo(4f).Within(0.0001f));
|
||||
Assert.That(pattern.AttackWidth, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EliteTuning_RotatesRequestedKindsAcrossSixTimedEvents()
|
||||
{
|
||||
GameObject owner = new("Stage Roster Tuning Test");
|
||||
try
|
||||
{
|
||||
SpawnDirector director = owner.AddComponent<SpawnDirector>();
|
||||
EnemyKind[] expected =
|
||||
{
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
EnemyKind.Werewolf,
|
||||
EnemyKind.Werebear,
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
EnemyKind.Werewolf,
|
||||
EnemyKind.Werebear,
|
||||
};
|
||||
|
||||
for (int i = 0; i < expected.Length; i++)
|
||||
{
|
||||
Assert.That(
|
||||
director.GetEventEnemyTuning(RunTimedEvent.Elite, i).PrefabKind,
|
||||
Is.EqualTo(expected[i]),
|
||||
$"elite ordinal {i}");
|
||||
}
|
||||
|
||||
EnemyKind midBossKind =
|
||||
director.GetEventEnemyTuning(RunTimedEvent.MidBoss).PrefabKind;
|
||||
Assert.That(
|
||||
midBossKind == EnemyKind.GreatswordSkeleton
|
||||
|| midBossKind == EnemyKind.NecroGolem,
|
||||
Is.True,
|
||||
"The 10 minute mid-boss must be selected from the two requested kinds.");
|
||||
Assert.That(
|
||||
director.GetEventEnemyTuning(RunTimedEvent.FinalBoss).PrefabKind,
|
||||
Is.EqualTo(EnemyKind.Necromancer));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StageSchedule_UsesFormalAndFiveTimesDebugTimes()
|
||||
{
|
||||
Assert.That(
|
||||
RunManager.GetEliteEventTimes(false),
|
||||
Is.EqualTo(new[] { 180f, 360f, 540f, 720f, 900f, 1080f }));
|
||||
Assert.That(
|
||||
RunManager.GetEliteEventTimes(true),
|
||||
Is.EqualTo(new[] { 36f, 72f, 108f, 144f, 180f, 216f }));
|
||||
Assert.That(
|
||||
RunManager.GetEventTime(RunTimedEvent.MidBoss, false),
|
||||
Is.EqualTo(600f));
|
||||
Assert.That(
|
||||
RunManager.GetEventTime(RunTimedEvent.MidBoss, true),
|
||||
Is.EqualTo(120f));
|
||||
Assert.That(
|
||||
RunManager.GetEventTime(RunTimedEvent.FinalBoss, false),
|
||||
Is.EqualTo(1200f));
|
||||
Assert.That(
|
||||
RunManager.GetEventTime(RunTimedEvent.FinalBoss, true),
|
||||
Is.EqualTo(240f));
|
||||
|
||||
Assert.That(
|
||||
RunManager.GetEliteEventTimes(RunMode.Production),
|
||||
Is.EqualTo(new[] { 180f, 360f, 540f, 720f }));
|
||||
Assert.That(
|
||||
RunManager.GetEventTime(RunTimedEvent.MidBoss, RunMode.Production),
|
||||
Is.EqualTo(600f));
|
||||
Assert.That(
|
||||
RunManager.GetEventTime(RunTimedEvent.FinalBoss, RunMode.Production),
|
||||
Is.EqualTo(900f));
|
||||
Assert.That(
|
||||
RunManager.GetProgressionTime(600f, useDebugTimes: false),
|
||||
Is.EqualTo(600f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AmbientTargets_PreserveFiveMinuteBandsAndSeparateRoles()
|
||||
{
|
||||
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(0f), Is.EqualTo(3));
|
||||
Assert.That(SpawnDirector.TargetNormalAliveCountAt(0f), Is.EqualTo(3));
|
||||
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(60f), Is.EqualTo(6));
|
||||
Assert.That(SpawnDirector.TargetNormalAliveCountAt(60f), Is.EqualTo(4));
|
||||
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(180f), Is.EqualTo(12));
|
||||
Assert.That(SpawnDirector.TargetNormalAliveCountAt(180f), Is.EqualTo(4));
|
||||
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(300f), Is.EqualTo(19));
|
||||
Assert.That(SpawnDirector.TargetNormalAliveCountAt(300f), Is.EqualTo(5));
|
||||
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(600f), Is.EqualTo(27));
|
||||
Assert.That(SpawnDirector.TargetNormalAliveCountAt(600f), Is.EqualTo(5));
|
||||
Assert.That(SpawnDirector.TargetAliveCountAt(600f), Is.EqualTo(32));
|
||||
}
|
||||
|
||||
private static EnemyDefinition LoadDefinition(string name)
|
||||
{
|
||||
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
|
||||
$"Assets/_Project/Constants/Enemies/{name}.asset");
|
||||
Assert.That(definition, Is.Not.Null, name);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static void AssertHorizontalRootPattern(
|
||||
string name,
|
||||
int index,
|
||||
float activeDuration,
|
||||
float animationLeadTime)
|
||||
{
|
||||
EnemyAttackPattern pattern = LoadDefinition(name).GetAttackPattern(index);
|
||||
Assert.That(
|
||||
pattern.DirectionMode,
|
||||
Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot),
|
||||
$"{name} pattern {index} direction");
|
||||
Assert.That(
|
||||
pattern.GeometryScaleMode,
|
||||
Is.EqualTo(EnemyAttackGeometryScaleMode.RootScale),
|
||||
$"{name} pattern {index} scale");
|
||||
Assert.That(
|
||||
pattern.ActiveDuration,
|
||||
Is.EqualTo(activeDuration).Within(0.0001f),
|
||||
$"{name} pattern {index} active");
|
||||
Assert.That(
|
||||
pattern.AnimationLeadTime,
|
||||
Is.EqualTo(animationLeadTime).Within(0.0001f),
|
||||
$"{name} pattern {index} lead");
|
||||
Assert.That(
|
||||
pattern.AttackRange,
|
||||
Is.GreaterThan(0f),
|
||||
$"{name} pattern {index} range");
|
||||
if (pattern.AttackShape == EnemyAttackShape.Box)
|
||||
{
|
||||
Assert.That(pattern.AttackLength, Is.GreaterThan(0f), name);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(pattern.AttackRadius, Is.GreaterThan(0f), name);
|
||||
Assert.That(
|
||||
pattern.AttackWidth,
|
||||
Is.GreaterThan(0f),
|
||||
$"{name} pattern {index} ground band");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertRootGroundPattern(
|
||||
EnemyAttackPattern pattern,
|
||||
float length,
|
||||
float width,
|
||||
Vector2 offset,
|
||||
float animationLeadTime)
|
||||
{
|
||||
Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box));
|
||||
Assert.That(
|
||||
pattern.DirectionMode,
|
||||
Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot));
|
||||
Assert.That(
|
||||
pattern.GeometryScaleMode,
|
||||
Is.EqualTo(EnemyAttackGeometryScaleMode.RootScale));
|
||||
Assert.That(pattern.AttackLength, Is.EqualTo(length).Within(0.0001f));
|
||||
Assert.That(pattern.AttackWidth, Is.EqualTo(width).Within(0.0001f));
|
||||
Assert.That(pattern.AttackOriginOffset, Is.EqualTo(offset));
|
||||
Assert.That(
|
||||
pattern.AnimationLeadTime,
|
||||
Is.EqualTo(animationLeadTime).Within(0.0001f));
|
||||
}
|
||||
|
||||
private static GameObject LoadPrefab(string name)
|
||||
{
|
||||
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
$"Assets/_Project/Prefabs/Enemies/{name}.prefab");
|
||||
Assert.That(prefab, Is.Not.Null, name);
|
||||
return prefab;
|
||||
}
|
||||
|
||||
private static void AssertVisualMaterial(GameObject prefab, string name)
|
||||
{
|
||||
SpriteRenderer renderer = prefab.GetComponent<SpriteRenderer>();
|
||||
Assert.That(renderer, Is.Not.Null, name);
|
||||
Assert.That(renderer.sprite, Is.Not.Null, name);
|
||||
Assert.That(renderer.sharedMaterial, Is.Not.Null, name);
|
||||
Assert.That(renderer.sharedMaterial.shader, Is.Not.Null, name);
|
||||
Assert.That(renderer.sharedMaterial.shader.isSupported, Is.True, name);
|
||||
}
|
||||
|
||||
private static void AssertMidBossPatterns(string name)
|
||||
{
|
||||
EnemyDefinition definition = LoadDefinition(name);
|
||||
Assert.That(definition.AttackPatternCount, Is.EqualTo(3), name);
|
||||
|
||||
HashSet<string> animationStates = new();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
EnemyAttackPattern pattern = definition.GetAttackPattern(i);
|
||||
Assert.That(pattern.AnimationState, Is.Not.Null.And.Not.Empty, $"{name} pattern {i}");
|
||||
Assert.That(animationStates.Add(pattern.AnimationState), Is.True, $"{name} pattern {i}");
|
||||
Assert.That(pattern.WarningDuration, Is.GreaterThan(0f), $"{name} pattern {i}");
|
||||
Assert.That(pattern.ActiveDuration, Is.GreaterThan(0f), $"{name} pattern {i}");
|
||||
Assert.That(pattern.RecoveryDuration, Is.GreaterThan(0f), $"{name} pattern {i}");
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RosterSpec
|
||||
{
|
||||
public RosterSpec(string name, EnemyKind kind, EnemyRole role)
|
||||
{
|
||||
Name = name;
|
||||
Kind = kind;
|
||||
Role = role;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public EnemyKind Kind { get; }
|
||||
public EnemyRole Role { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4aaf0c1d1f954b7f8bf4e2f4c7c94e61
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Enemies;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class SummonAnimationContractTests
|
||||
{
|
||||
[TestCase(false, 1.25f)]
|
||||
[TestCase(true, 1.875f)]
|
||||
public void SummonCircle_FollowsShadowCenterAcrossFramesAndMovement(bool flip, float scale)
|
||||
{
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(
|
||||
"Assets/_Project/Art/Characters/Necromancer/Necromancer_Summon-shadow-v2.png")
|
||||
.OfType<Sprite>().ToArray();
|
||||
Assert.That(sprites.Length, Is.EqualTo(10));
|
||||
var owner = new GameObject("Summon anchor test");
|
||||
StageEnemyEffectVisual effect = null;
|
||||
try
|
||||
{
|
||||
var body = owner.AddComponent<SpriteRenderer>();
|
||||
body.sprite = sprites[0];
|
||||
body.flipX = flip;
|
||||
owner.transform.localScale = new Vector3(scale, scale, 1f);
|
||||
effect = StageEnemyEffectVisual.PlaySummon(owner, owner.transform.position, 1.25f);
|
||||
var lateUpdate = typeof(StageEnemyEffectVisual).GetMethod("LateUpdate",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
for (int i = 0; i < sprites.Length; i++)
|
||||
{
|
||||
body.sprite = sprites[i];
|
||||
owner.transform.position = new Vector3(i * .3f, i * -.2f, 0f);
|
||||
lateUpdate.Invoke(effect, null);
|
||||
// Measured source shadow center, relative to the imported 50px pivot.
|
||||
Vector3 expected = owner.transform.position
|
||||
+ new Vector3((flip ? -.5f : .5f) / 32f, -7.5f / 32f) * scale;
|
||||
Assert.That(Vector3.Distance(effect.transform.position, expected), Is.LessThan(.0001f));
|
||||
Assert.That(effect.transform.localScale, Is.EqualTo(Vector3.one));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (effect != null) Object.DestroyImmediate(effect.gameObject);
|
||||
Object.DestroyImmediate(owner);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("Skeleton", 5)]
|
||||
[TestCase("GreatswordSkeleton", 5)]
|
||||
[TestCase("ArmoredSkeleton", 5)]
|
||||
[TestCase("SkeletonArcher", 5)]
|
||||
[TestCase("Necromancer", 10)]
|
||||
[TestCase("Bat", 4)]
|
||||
[TestCase("Slime", 4)]
|
||||
[TestCase("Necrofire", 6)]
|
||||
[TestCase("Werewolf", 4)]
|
||||
[TestCase("Werebear", 4)]
|
||||
[TestCase("NecroGolem", 6)]
|
||||
public void Summon_PreservesEveryAuthoredFrameAndCannotExitEarly(string name, int frames)
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
$"Assets/_Project/Prefabs/Enemies/{name}.prefab");
|
||||
var controller = prefab.GetComponent<Animator>().runtimeAnimatorController as AnimatorController;
|
||||
Assert.That(controller, Is.Not.Null);
|
||||
var summon = controller.layers[0].stateMachine.states
|
||||
.Select(child => child.state).Single(state => state.name == "Summon");
|
||||
var clip = summon.motion as AnimationClip;
|
||||
Assert.That(clip, Is.Not.Null);
|
||||
Assert.That(clip.frameRate, Is.EqualTo(8f));
|
||||
Assert.That(clip.length, Is.EqualTo(frames / 8f).Within(.0001f));
|
||||
Assert.That(clip.isLooping, Is.False);
|
||||
var binding = AnimationUtility.GetObjectReferenceCurveBindings(clip)
|
||||
.Single(b => b.type == typeof(SpriteRenderer) && b.propertyName == "m_Sprite");
|
||||
var keys = AnimationUtility.GetObjectReferenceCurve(clip, binding);
|
||||
Assert.That(keys.Length, Is.EqualTo(frames));
|
||||
for (int i = 0; i < keys.Length; i++)
|
||||
{
|
||||
Assert.That(keys[i].value, Is.Not.Null);
|
||||
Assert.That(keys[i].time, Is.EqualTo(i / 8f).Within(.0001f));
|
||||
var texturePath = AssetDatabase.GetAssetPath(keys[i].value);
|
||||
var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
|
||||
Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point));
|
||||
Assert.That(importer.spritePixelsPerUnit, Is.EqualTo(32f));
|
||||
if (name == "GreatswordSkeleton" || name == "ArmoredSkeleton")
|
||||
{
|
||||
var sprite = (Sprite)keys[i].value;
|
||||
var masks = Resources.LoadAll<Sprite>(
|
||||
"Enemies/Protection-v2/" + sprite.texture.name + "-outline");
|
||||
Assert.That(masks.Any(mask => mask.rect == sprite.rect), Is.True,
|
||||
"Protected summons must retain their outline during every rise frame.");
|
||||
}
|
||||
}
|
||||
Assert.That(summon.transitions, Is.Not.Empty);
|
||||
foreach (var transition in summon.transitions)
|
||||
{
|
||||
Assert.That(transition.hasExitTime, Is.True);
|
||||
Assert.That(transition.exitTime, Is.EqualTo(1f));
|
||||
Assert.That(transition.duration, Is.Zero);
|
||||
// Attack exits are deliberately left independent of the summon clock.
|
||||
foreach (var child in controller.layers[0].stateMachine.states)
|
||||
if (child.state != summon)
|
||||
Assert.That(child.state.transitions.Contains(transition), Is.False);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("Bat")]
|
||||
[TestCase("Slime")]
|
||||
[TestCase("Necrofire")]
|
||||
[TestCase("Werewolf")]
|
||||
[TestCase("Werebear")]
|
||||
[TestCase("NecroGolem")]
|
||||
public void DerivedRise_ReusesOriginalDeathSpritesInReverse(string name)
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
|
||||
$"Assets/_Project/Prefabs/Enemies/{name}.prefab");
|
||||
var controller = prefab.GetComponent<Animator>().runtimeAnimatorController as AnimatorController;
|
||||
var states = controller.layers[0].stateMachine.states;
|
||||
var death = (AnimationClip)states.Single(s => s.state.name == "Death").state.motion;
|
||||
var rise = (AnimationClip)states.Single(s => s.state.name == "Summon").state.motion;
|
||||
var deathBinding = AnimationUtility.GetObjectReferenceCurveBindings(death).Single();
|
||||
var riseBinding = AnimationUtility.GetObjectReferenceCurveBindings(rise).Single();
|
||||
var deathFrames = AnimationUtility.GetObjectReferenceCurve(death, deathBinding);
|
||||
var riseFrames = AnimationUtility.GetObjectReferenceCurve(rise, riseBinding);
|
||||
Assert.That(riseFrames.Select(f => f.value),
|
||||
Is.EqualTo(deathFrames.Reverse().Select(f => f.value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 949061bed93945da8db4eb3563b5185b
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace BumpCombat.Tests
|
||||
{
|
||||
public sealed class UserProfileStoreTests
|
||||
{
|
||||
private string testDirectory;
|
||||
private string profilePath;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
testDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"BumpCombat-profile-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(testDirectory);
|
||||
profilePath = Path.Combine(testDirectory, UserProfileStore.DefaultFileName);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(testDirectory))
|
||||
{
|
||||
Directory.Delete(testDirectory, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MissionTotalsAndSkinFields_RoundTripWithStableSteamIntMappings()
|
||||
{
|
||||
UserProfileStore store = new(profilePath);
|
||||
Assert.That(store.CanSave, Is.True);
|
||||
store.Profile.unlockedSkinIds.Add("skin.future.test");
|
||||
store.Profile.selectedSkinId = "skin.future.test";
|
||||
Assert.That(store.IncrementMissionCompletion(MissionIds.FirstSweep), Is.True);
|
||||
|
||||
UserProfileStore loaded = new(profilePath);
|
||||
Assert.That(loaded.Profile.schemaVersion, Is.EqualTo(1));
|
||||
Assert.That(loaded.GetMissionCompletions(MissionIds.FirstSweep), Is.EqualTo(1));
|
||||
Assert.That(loaded.Profile.unlockedSkinIds, Does.Contain("skin.future.test"));
|
||||
Assert.That(loaded.Profile.selectedSkinId, Is.EqualTo("skin.future.test"));
|
||||
|
||||
SteamIntegerStatSnapshot[] stats = loaded.CreateSteamIntegerStatSnapshot();
|
||||
Assert.That(stats.Length, Is.EqualTo(11));
|
||||
Assert.That(stats[0].ApiName, Is.EqualTo("bc_mission_first_sweep_completions"));
|
||||
Assert.That(stats[0].Value, Is.EqualTo(1));
|
||||
Assert.That(stats[^1].ApiName, Is.EqualTo("bc_mission_rampage_completions"));
|
||||
|
||||
Assert.That(loaded.UnlockAchievement("achievement.fixture"), Is.True);
|
||||
Assert.That(loaded.UnlockAchievement("achievement.fixture"), Is.True);
|
||||
Assert.That(loaded.Profile.achievements.Count, Is.EqualTo(1));
|
||||
string[] unlocked = loaded.CreateSteamUnlockedAchievementApiNameSnapshot(
|
||||
new[]
|
||||
{
|
||||
new SteamAchievementDefinition(
|
||||
"achievement.fixture",
|
||||
"BC_FIXTURE_ACHIEVEMENT"),
|
||||
new SteamAchievementDefinition(
|
||||
"achievement.unlocked.but.unmapped",
|
||||
"BC_UNMAPPED_ACHIEVEMENT"),
|
||||
});
|
||||
Assert.That(unlocked, Is.EqualTo(new[] { "BC_FIXTURE_ACHIEVEMENT" }));
|
||||
Assert.That(
|
||||
loaded.CreateSteamUnlockedAchievementApiNameSnapshot(
|
||||
Array.Empty<SteamAchievementDefinition>()),
|
||||
Is.Empty);
|
||||
}
|
||||
|
||||
[TestCase("{}")]
|
||||
[TestCase("{ \"missionTotals\": [] }")]
|
||||
[TestCase("{")]
|
||||
public void MissingOrMalformedSchema_PreservesOriginalFile(string json)
|
||||
{
|
||||
File.WriteAllText(profilePath, json);
|
||||
UserProfileStore store = new(profilePath);
|
||||
|
||||
Assert.That(store.CanSave, Is.False);
|
||||
Assert.That(store.LoadIssue, Is.EqualTo("malformed"));
|
||||
Assert.That(store.IncrementMissionCompletion(MissionIds.Rampage), Is.False);
|
||||
Assert.That(File.ReadAllText(profilePath), Is.EqualTo(json));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NewerSchema_PreservesOriginalFileAndSaturatesSteamIntAtSignedMaximum()
|
||||
{
|
||||
const string newerJson = "{\"schemaVersion\":2,\"missionTotals\":[]}";
|
||||
File.WriteAllText(profilePath, newerJson);
|
||||
UserProfileStore future = new(profilePath);
|
||||
Assert.That(future.CanSave, Is.False);
|
||||
Assert.That(future.LoadIssue, Is.EqualTo("newer-schema"));
|
||||
Assert.That(future.IncrementMissionCompletion(MissionIds.Rampage), Is.False);
|
||||
Assert.That(File.ReadAllText(profilePath), Is.EqualTo(newerJson));
|
||||
|
||||
const string fullJson = "{\"schemaVersion\":1,\"missionTotals\":["
|
||||
+ "{\"missionId\":\"mission.rampage\",\"completions\":2147483647}]}";
|
||||
File.WriteAllText(profilePath, fullJson);
|
||||
UserProfileStore full = new(profilePath);
|
||||
Assert.That(full.IncrementMissionCompletion(MissionIds.Rampage), Is.True);
|
||||
Assert.That(full.GetMissionCompletions(MissionIds.Rampage), Is.EqualTo(int.MaxValue));
|
||||
SteamIntegerStatSnapshot rampage = full.CreateSteamIntegerStatSnapshot()[^1];
|
||||
Assert.That(rampage.Value, Is.EqualTo(int.MaxValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RepeatedAchievementUnlock_RetriesAfterTransientSaveFailure()
|
||||
{
|
||||
string blockingFile = Path.Combine(testDirectory, "blocking-file");
|
||||
File.WriteAllText(blockingFile, "not a directory");
|
||||
string blockedProfilePath = Path.Combine(blockingFile, "profile.json");
|
||||
UserProfileStore store = new(blockedProfilePath);
|
||||
|
||||
Assert.That(store.UnlockAchievement("achievement.retry"), Is.False);
|
||||
Assert.That(store.LoadIssue, Is.EqualTo("save-failed"));
|
||||
Assert.That(store.UnlockAchievement("achievement.retry"), Is.False,
|
||||
"A repeated call must retry persistence while the path is still blocked.");
|
||||
|
||||
File.Delete(blockingFile);
|
||||
Directory.CreateDirectory(blockingFile);
|
||||
Assert.That(store.UnlockAchievement("achievement.retry"), Is.True);
|
||||
Assert.That(store.LoadIssue, Is.Null);
|
||||
Assert.That(File.Exists(blockedProfilePath), Is.True);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1bc168e553084176b88e9e35be42cc7c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ac7c65b8a57e4c4987d3573b6c1b8ee
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Collections;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.Core;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class ArtifactBalanceBuffRegressionTests
|
||||
{
|
||||
private GameObject isolationOwner;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (isolationOwner != null)
|
||||
{
|
||||
Object.Destroy(isolationOwner);
|
||||
}
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EnemyCharacterModel_ModifiersDriveRuntimeAndRemainInstanceLocal()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
director.enabled = false;
|
||||
}
|
||||
|
||||
EnemyController enemy = FindOrdinaryEnemy();
|
||||
if (enemy == null && director != null)
|
||||
{
|
||||
director.DebugSpawnImmediate(1);
|
||||
yield return null;
|
||||
enemy = FindOrdinaryEnemy();
|
||||
}
|
||||
|
||||
Assert.That(enemy, Is.Not.Null);
|
||||
Assert.That(enemy.IsDead, Is.False);
|
||||
Assert.That(enemy.IsEventEnemy, Is.False);
|
||||
Assert.That(enemy.IsGroggyTierGated, Is.False);
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(player, Is.Not.Null);
|
||||
PlayerStats playerStats = player.GetComponent<PlayerStats>();
|
||||
EnemyModel enemyModel = enemy.GetComponent<EnemyModel>();
|
||||
Assert.That(enemyModel, Is.Not.Null);
|
||||
|
||||
float initialHealth = enemy.CurrentHealth;
|
||||
float initialMaximumHealth = enemy.MaximumHealth;
|
||||
float initialMoveSpeed = enemy.MoveSpeed;
|
||||
float initialAttackDamage = enemy.AttackDamage;
|
||||
Assert.That(initialMaximumHealth, Is.GreaterThan(0f));
|
||||
Assert.That(initialHealth, Is.GreaterThan(2f));
|
||||
Assert.That(initialMoveSpeed, Is.GreaterThan(0f));
|
||||
Assert.That(initialAttackDamage, Is.GreaterThan(0f));
|
||||
|
||||
Assert.That(enemyModel.AddModifier(new StatModifier(
|
||||
"test.enemy.max-health",
|
||||
CharacterStat.MaxHealth,
|
||||
ModifierOperation.Increased,
|
||||
0.5f)), Is.True);
|
||||
Assert.That(enemy.MaximumHealth, Is.EqualTo(initialMaximumHealth * 1.5f).Within(0.001f));
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(initialHealth).Within(0.001f));
|
||||
|
||||
Assert.That(enemyModel.AddModifier(new StatModifier(
|
||||
"test.enemy.move-speed",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.25f)), Is.True);
|
||||
Assert.That(enemy.MoveSpeed, Is.EqualTo(initialMoveSpeed * 1.25f).Within(0.001f));
|
||||
|
||||
Assert.That(enemyModel.AddModifier(new StatModifier(
|
||||
"test.enemy.attack-damage",
|
||||
CharacterStat.AttackDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.25f)), Is.True);
|
||||
Assert.That(enemy.AttackDamage, Is.EqualTo(initialAttackDamage * 1.25f).Within(0.001f));
|
||||
|
||||
Assert.That(enemyModel.AddModifier(new StatModifier(
|
||||
"test.enemy.incoming-damage",
|
||||
CharacterStat.IncomingDamage,
|
||||
ModifierOperation.Increased,
|
||||
0.25f)), Is.True);
|
||||
Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True);
|
||||
Assert.That(enemy.TryTakeDamage(1.4f, Vector2.zero, 0f, false), Is.True);
|
||||
Assert.That(enemy.LastAppliedDamage, Is.EqualTo(2f).Within(0.001f));
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(initialHealth - 2f).Within(0.001f));
|
||||
|
||||
isolationOwner = new GameObject("Second Enemy Model");
|
||||
EnemyModel secondEnemyModel = isolationOwner.AddComponent<EnemyModel>();
|
||||
secondEnemyModel.ConfigureDefinition(enemy.Definition);
|
||||
Assert.That(
|
||||
secondEnemyModel.GetStackCount(
|
||||
"test.enemy.move-speed",
|
||||
CharacterStat.MoveSpeed),
|
||||
Is.Zero);
|
||||
Assert.That(
|
||||
playerStats.GetStackCount(
|
||||
"test.enemy.move-speed",
|
||||
CharacterStat.MoveSpeed),
|
||||
Is.Zero);
|
||||
|
||||
typeof(EnemyController)
|
||||
.GetProperty(nameof(enemy.CurrentHealth))
|
||||
.GetSetMethod(true)
|
||||
.Invoke(enemy, new object[] { enemy.MaximumHealth });
|
||||
enemy.enabled = false;
|
||||
Assert.That(
|
||||
enemyModel.RemoveModifiersFromSource("test.enemy.max-health"),
|
||||
Is.EqualTo(1));
|
||||
Assert.That(enemy.MaximumHealth, Is.EqualTo(initialMaximumHealth).Within(0.001f));
|
||||
Assert.That(enemy.CurrentHealth, Is.GreaterThan(enemy.MaximumHealth));
|
||||
|
||||
enemy.enabled = true;
|
||||
Assert.That(enemy.CurrentHealth, Is.EqualTo(initialMaximumHealth).Within(0.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator IgniteLethalTick_UsesSceneDeathLifecycleOnce()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
|
||||
EnemyController enemy = Object.FindAnyObjectByType<EnemyController>();
|
||||
if (enemy == null)
|
||||
{
|
||||
director.DebugSpawnImmediate(1);
|
||||
yield return null;
|
||||
enemy = Object.FindAnyObjectByType<EnemyController>();
|
||||
}
|
||||
|
||||
Assert.That(enemy, Is.Not.Null);
|
||||
int deathCount = 0;
|
||||
bool effectsClearedAtDeath = false;
|
||||
enemy.OnDied += deadEnemy =>
|
||||
{
|
||||
deathCount++;
|
||||
effectsClearedAtDeath = deadEnemy.IsDead
|
||||
&& !deadEnemy.IsIgnited
|
||||
&& !deadEnemy.IsShocked
|
||||
&& !deadEnemy.IsBumpVulnerable;
|
||||
};
|
||||
|
||||
Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True);
|
||||
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True);
|
||||
Assert.That(
|
||||
enemy.ApplyIgnite(3f, 0.5f, enemy.CurrentHealth),
|
||||
Is.True);
|
||||
|
||||
float timeout = 1.5f;
|
||||
while (deathCount == 0 && timeout > 0f)
|
||||
{
|
||||
timeout -= Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(deathCount, Is.EqualTo(1));
|
||||
Assert.That(effectsClearedAtDeath, Is.True);
|
||||
|
||||
float despawnDeadline = Time.realtimeSinceStartup + 3f;
|
||||
while (enemy != null
|
||||
&& Time.realtimeSinceStartup < despawnDeadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(enemy == null, Is.True);
|
||||
Assert.That(deathCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PulseAndCycloneBuffs_SurviveSelectionChangesWithExactValues()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Pulse);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.IncomingDamage, 20f),
|
||||
Is.EqualTo(16f).Within(0.001f));
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"pulse.damage-reduction",
|
||||
CharacterStat.IncomingDamage),
|
||||
Is.EqualTo(1));
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.IncomingDamage, 20f),
|
||||
Is.EqualTo(16f).Within(0.001f));
|
||||
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True);
|
||||
Assert.That(
|
||||
stats.MoveSpeed,
|
||||
Is.EqualTo(3.45f).Within(0.001f));
|
||||
|
||||
yield return new WaitForSecondsRealtime(0.45f);
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True);
|
||||
Assert.That(
|
||||
stats.MoveSpeed,
|
||||
Is.EqualTo(3.45f).Within(0.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneBuff_AddsToGrowthAndPreservesGrowthAfterExpiry()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
Assert.That(
|
||||
stats.AddModifier(new StatModifier(
|
||||
"growth.move-speed",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.1f)),
|
||||
Is.True);
|
||||
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(stats.MoveSpeed, Is.EqualTo(3.75f).Within(0.001f));
|
||||
|
||||
yield return new WaitForSecondsRealtime(3.15f);
|
||||
|
||||
Assert.That(stats.MoveSpeed, Is.EqualTo(3.3f).Within(0.001f));
|
||||
Assert.That(
|
||||
stats.GetStackCount("growth.move-speed", CharacterStat.MoveSpeed),
|
||||
Is.EqualTo(1));
|
||||
Assert.That(
|
||||
stats.GetStackCount("cyclone.move-speed", CharacterStat.MoveSpeed),
|
||||
Is.Zero);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneBuff_StrongReapplicationWinsOverWeakReapplication()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
|
||||
|
||||
Assert.That(artifacts.TryUseCurrent(true), Is.True);
|
||||
yield return new WaitForSecondsRealtime(0.75f);
|
||||
Assert.That(artifacts.IsExecutingArtifact, Is.False);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(stats.MoveSpeed, Is.EqualTo(3.75f).Within(0.001f));
|
||||
|
||||
yield return new WaitForSecondsRealtime(3.15f);
|
||||
Assert.That(
|
||||
stats.MoveSpeed,
|
||||
Is.EqualTo(3.75f).Within(0.001f),
|
||||
"A weak reapplication must not shorten the strong buff.");
|
||||
|
||||
yield return new WaitForSecondsRealtime(1.05f);
|
||||
Assert.That(stats.MoveSpeed, Is.EqualTo(3f).Within(0.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TemporaryBuffs_PauseTimersAndClearOnControllerDisable()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Pulse);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"pulse.damage-reduction",
|
||||
CharacterStat.IncomingDamage),
|
||||
Is.EqualTo(1));
|
||||
|
||||
Time.timeScale = 0f;
|
||||
yield return new WaitForSecondsRealtime(0.35f);
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"pulse.damage-reduction",
|
||||
CharacterStat.IncomingDamage),
|
||||
Is.EqualTo(1));
|
||||
Assert.That(
|
||||
stats.Evaluate(CharacterStat.IncomingDamage, 20f),
|
||||
Is.EqualTo(16f).Within(0.001f));
|
||||
|
||||
artifacts.enabled = false;
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"pulse.damage-reduction",
|
||||
CharacterStat.IncomingDamage),
|
||||
Is.Zero);
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneBuff_CancelledByDamageStillExpiresAndPreservesGrowth()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
PlayerHealth health = player.GetComponent<PlayerHealth>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
Assert.That(
|
||||
stats.AddModifier(new StatModifier(
|
||||
"growth.move-speed.cancel-test",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.1f)),
|
||||
Is.True);
|
||||
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(artifacts.IsExecutingArtifact, Is.True);
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
|
||||
for (int frame = 0;
|
||||
frame < 30 && artifacts.IsExecutingArtifact;
|
||||
frame++)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(artifacts.IsExecutingArtifact, Is.False);
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSecondsRealtime(3.2f);
|
||||
|
||||
Assert.That(
|
||||
stats.MoveSpeed,
|
||||
Is.EqualTo(3.3f).Within(0.001f),
|
||||
"Cancelling CY must not orphan its three-second expiration.");
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"growth.move-speed.cancel-test",
|
||||
CharacterStat.MoveSpeed),
|
||||
Is.EqualTo(1));
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
"cyclone.move-speed",
|
||||
CharacterStat.MoveSpeed),
|
||||
Is.Zero);
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load =
|
||||
SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static EnemyController FindOrdinaryEnemy()
|
||||
{
|
||||
EnemyController[] enemies =
|
||||
Object.FindObjectsByType<EnemyController>(FindObjectsSortMode.None);
|
||||
foreach (EnemyController enemy in enemies)
|
||||
{
|
||||
if (enemy != null
|
||||
&& !enemy.IsDead
|
||||
&& !enemy.IsEventEnemy
|
||||
&& !enemy.IsGroggyTierGated)
|
||||
{
|
||||
return enemy;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void SelectArtifact(
|
||||
ActiveArtifactController artifacts,
|
||||
ActiveArtifactEffect effect)
|
||||
{
|
||||
Assert.That(artifacts.OwnedArtifactCount, Is.GreaterThanOrEqualTo(2));
|
||||
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
|
||||
{
|
||||
if (artifacts.CurrentArtifact != null
|
||||
&& artifacts.CurrentArtifact.Effect == effect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
}
|
||||
|
||||
Assert.Fail($"Artifact {effect} was not found in the debug catalog.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f74b8c1d6e94a52b0c9f8a7e1d26345
|
||||
@@ -0,0 +1,571 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class ArtifactChargeVisualPlayModeTests
|
||||
{
|
||||
private Keyboard virtualKeyboard;
|
||||
private bool virtualKeyboardInputActive;
|
||||
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
|
||||
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
|
||||
private bool previousRunInBackground;
|
||||
private bool inputSettingsCaptured;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (virtualKeyboardInputActive
|
||||
&& virtualKeyboard != null
|
||||
&& virtualKeyboard.added)
|
||||
{
|
||||
InputSystem.QueueStateEvent(virtualKeyboard, new KeyboardState());
|
||||
InputSystem.RemoveDevice(virtualKeyboard);
|
||||
}
|
||||
|
||||
virtualKeyboard = null;
|
||||
virtualKeyboardInputActive = false;
|
||||
if (inputSettingsCaptured)
|
||||
{
|
||||
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
previousEditorInputBehavior;
|
||||
Application.runInBackground = previousRunInBackground;
|
||||
inputSettingsCaptured = false;
|
||||
}
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Switching_ShowsDestinationBurstOnceAndCleansUp()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
ActiveArtifactController artifacts = Object.FindAnyObjectByType<ActiveArtifactController>();
|
||||
ArtifactChargeVisual visual = artifacts.GetComponent<ArtifactChargeVisual>();
|
||||
Object.FindAnyObjectByType<SpawnDirector>().enabled = false;
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(FindObjectsSortMode.None))
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
Assert.That(visual.IsSwitchVisible, Is.False, "Initial acquisition is not a switch.");
|
||||
float gauge = artifacts.CurrentGauge;
|
||||
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
|
||||
{
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
Assert.That(visual.IsSwitchVisible, Is.True);
|
||||
SpriteRenderer burst = visual.SwitchObject.GetComponent<SpriteRenderer>();
|
||||
Assert.That(burst.sprite.texture.name,
|
||||
Is.EqualTo(artifacts.CurrentArtifact.Effect + "-Release-v1"));
|
||||
Assert.That(burst.color, Is.EqualTo(Color.white));
|
||||
Assert.That(visual.SwitchObject.transform.parent, Is.EqualTo(artifacts.transform));
|
||||
yield return null;
|
||||
int bursts = 0;
|
||||
foreach (SpriteRenderer renderer in artifacts.GetComponentsInChildren<SpriteRenderer>())
|
||||
{
|
||||
if (renderer.name == "Artifact Switch") bursts++;
|
||||
}
|
||||
Assert.That(bursts, Is.EqualTo(1), "Rapid switches replace the previous burst.");
|
||||
}
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(gauge));
|
||||
GameObject activeBurst = visual.SwitchObject;
|
||||
Sprite pausedFrame = activeBurst.GetComponent<SpriteRenderer>().sprite;
|
||||
Time.timeScale = 0f;
|
||||
yield return new WaitForSecondsRealtime(0.4f);
|
||||
Assert.That(visual.SwitchObject, Is.EqualTo(activeBurst));
|
||||
Assert.That(activeBurst.GetComponent<SpriteRenderer>().sprite, Is.EqualTo(pausedFrame));
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSeconds(ArtifactChargeVisual.SwitchVisualDuration + 0.05f);
|
||||
Assert.That(visual.IsSwitchVisible, Is.False);
|
||||
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
visual.enabled = false;
|
||||
Assert.That(visual.IsSwitchVisible, Is.False);
|
||||
visual.enabled = true;
|
||||
Assert.That(visual.IsSwitchVisible, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AInput_ChargeVisualsFollowReadyFlashAndReleaseOnce()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
director.enabled = false;
|
||||
}
|
||||
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
Rigidbody2D body = player.GetComponent<Rigidbody2D>();
|
||||
ArtifactChargeVisual visual =
|
||||
player.GetComponent<ArtifactChargeVisual>();
|
||||
Assert.That(visual, Is.Not.Null);
|
||||
SpriteRenderer playerRenderer = player.GetComponent<SpriteRenderer>();
|
||||
Assert.That(playerRenderer, Is.Not.Null);
|
||||
|
||||
ActiveArtifactDefinition definition = SelectArtifact(
|
||||
artifacts,
|
||||
ActiveArtifactEffect.Dash);
|
||||
EnsureGauge(artifacts, definition.ChargedGaugeCost);
|
||||
Vector2 start = player.transform.position;
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A));
|
||||
yield return null;
|
||||
Assert.That(keyboard.aKey.isPressed, Is.True);
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
SpriteRenderer auraRenderer =
|
||||
visual.AuraObject.GetComponent<SpriteRenderer>();
|
||||
Assert.That(auraRenderer.sortingLayerID,
|
||||
Is.EqualTo(playerRenderer.sortingLayerID));
|
||||
Assert.That(auraRenderer.sortingOrder,
|
||||
Is.EqualTo(playerRenderer.sortingOrder - 1));
|
||||
Assert.That(
|
||||
visual.AuraObject.transform.position,
|
||||
Is.EqualTo((Vector3)(start + Vector2.up * ArtifactChargeVisual.ChargeVisualY)));
|
||||
|
||||
body.position = start + Vector2.right;
|
||||
Physics2D.SyncTransforms();
|
||||
yield return null;
|
||||
Vector2 movedPlayerPosition = player.transform.position;
|
||||
Assert.That(
|
||||
visual.AuraObject.transform.position,
|
||||
Is.EqualTo((Vector3)(movedPlayerPosition
|
||||
+ Vector2.up * ArtifactChargeVisual.ChargeVisualY)));
|
||||
|
||||
yield return new WaitForSeconds(definition.ChargeDuration + 0.05f);
|
||||
Assert.That(visual.IsReadyAuraVisible, Is.True);
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
Assert.That(visual.FlashPlayCount, Is.EqualTo(1));
|
||||
|
||||
yield return null;
|
||||
Assert.That(visual.FlashPlayCount, Is.EqualTo(1));
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(keyboard.aKey.isPressed, Is.False);
|
||||
Vector2 releaseOrigin = artifacts.LastArtifactUsePosition;
|
||||
Assert.That(visual.IsReadyAuraVisible, Is.False);
|
||||
Assert.That(visual.ReleasePlayCount, Is.EqualTo(1));
|
||||
Assert.That(visual.IsReleaseVisible, Is.True);
|
||||
SpriteRenderer releaseRenderer =
|
||||
visual.ReleaseObject.GetComponent<SpriteRenderer>();
|
||||
Assert.That(releaseRenderer.sortingLayerID,
|
||||
Is.EqualTo(playerRenderer.sortingLayerID));
|
||||
Assert.That(releaseRenderer.sortingOrder,
|
||||
Is.EqualTo(playerRenderer.sortingOrder + 1));
|
||||
Assert.That(
|
||||
visual.ReleaseObject.transform.position,
|
||||
Is.EqualTo((Vector3)(releaseOrigin
|
||||
+ Vector2.up * ArtifactChargeVisual.ChargeVisualY)));
|
||||
|
||||
body.position = releaseOrigin + Vector2.left;
|
||||
Physics2D.SyncTransforms();
|
||||
yield return null;
|
||||
Assert.That(
|
||||
visual.ReleaseObject.transform.position,
|
||||
Is.EqualTo((Vector3)(releaseOrigin
|
||||
+ Vector2.up * ArtifactChargeVisual.ChargeVisualY)));
|
||||
|
||||
Sprite pausedReleaseFrame = releaseRenderer.sprite;
|
||||
Assert.That(RunManager.Instance.TryOpenPause(), Is.True);
|
||||
yield return new WaitForSecondsRealtime(0.18f);
|
||||
Assert.That(visual.IsReleaseVisible, Is.True);
|
||||
Assert.That(releaseRenderer.sprite, Is.SameAs(pausedReleaseFrame));
|
||||
Assert.That(RunManager.Instance.ClosePause(), Is.True);
|
||||
yield return WaitForArtifactCompletion(artifacts, visual);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ChainLightning_NoTargetDoesNotEmitRelease()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
director.enabled = false;
|
||||
}
|
||||
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
ArtifactChargeVisual visual =
|
||||
player.GetComponent<ArtifactChargeVisual>();
|
||||
ActiveArtifactDefinition definition = SelectArtifact(
|
||||
artifacts,
|
||||
ActiveArtifactEffect.ChainLightning);
|
||||
EnsureGauge(artifacts, definition.NormalGaugeCost);
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A));
|
||||
yield return null;
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
|
||||
Assert.That(visual.ReleasePlayCount, Is.Zero);
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
Assert.That(visual.IsReadyAuraVisible, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ChargeVisual_CancelDamageSelectionAndDisableClearImmediately()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
ArtifactChargeVisual visual =
|
||||
player.GetComponent<ArtifactChargeVisual>();
|
||||
PlayerHealth health = player.GetComponent<PlayerHealth>();
|
||||
ActiveArtifactDefinition definition = SelectArtifact(
|
||||
artifacts,
|
||||
ActiveArtifactEffect.Dash);
|
||||
EnsureGauge(artifacts, definition.NormalGaugeCost);
|
||||
|
||||
yield return PressA(keyboard);
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
Assert.That(RunManager.Instance.TryOpenPause(), Is.True);
|
||||
yield return null;
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
Assert.That(RunManager.Instance.ClosePause(), Is.True);
|
||||
|
||||
yield return ReleaseA(keyboard);
|
||||
yield return PressA(keyboard);
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
Assert.That(
|
||||
health.TryTakeDamage(1f, Vector2.left, 0f),
|
||||
Is.True);
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
|
||||
yield return ReleaseA(keyboard);
|
||||
yield return new WaitForSeconds(0.6f);
|
||||
yield return PressA(keyboard);
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
visual.enabled = false;
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
visual.enabled = true;
|
||||
yield return null;
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
|
||||
RunManager.Instance.SetSelectionOpen(true);
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
yield return null;
|
||||
RunManager.Instance.SetSelectionOpen(false);
|
||||
yield return null;
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
|
||||
yield return ReleaseA(keyboard);
|
||||
yield return PressA(keyboard);
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
artifacts.enabled = false;
|
||||
yield return null;
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
artifacts.enabled = true;
|
||||
yield return ReleaseA(keyboard);
|
||||
|
||||
SetGauge(artifacts, 0f);
|
||||
yield return PressA(keyboard);
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
yield return ReleaseA(keyboard);
|
||||
|
||||
// Hurt ends before damage immunity. The death cleanup check needs
|
||||
// a new accepted hit after the earlier hit's protection expires.
|
||||
float immunityDeadline = Time.realtimeSinceStartup
|
||||
+ health.InvulnerabilityDuration + 1f;
|
||||
while (health.IsInvulnerable
|
||||
&& Time.realtimeSinceStartup < immunityDeadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
Assert.That(health.IsInvulnerable, Is.False);
|
||||
|
||||
EnsureGauge(artifacts, definition.NormalGaugeCost);
|
||||
yield return PressA(keyboard);
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
Assert.That(
|
||||
health.TryTakeDamage(health.CurrentHealth + 1f, Vector2.left, 0f),
|
||||
Is.True);
|
||||
Assert.That(visual.IsAuraVisible, Is.False);
|
||||
Assert.That(visual.IsReadyAuraVisible, Is.False);
|
||||
Assert.That(visual.IsFlashVisible, Is.False);
|
||||
Assert.That(visual.IsReleaseVisible, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SixArtifacts_NormalAndChargedUseEmitOneReleaseEach()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
director.enabled = false;
|
||||
}
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
ArtifactChargeVisual visual =
|
||||
player.GetComponent<ArtifactChargeVisual>();
|
||||
BeginVirtualKeyboardInput();
|
||||
ActiveArtifactEffect[] effects =
|
||||
{
|
||||
ActiveArtifactEffect.Dash,
|
||||
ActiveArtifactEffect.Pulse,
|
||||
ActiveArtifactEffect.Phoenix,
|
||||
ActiveArtifactEffect.Cyclone,
|
||||
ActiveArtifactEffect.ThunderCrash,
|
||||
ActiveArtifactEffect.ChainLightning,
|
||||
};
|
||||
|
||||
EnemyController chainTarget = null;
|
||||
for (int i = 0; i < effects.Length; i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition = SelectArtifact(
|
||||
artifacts,
|
||||
effects[i]);
|
||||
EnsureGauge(artifacts, definition.ChargedGaugeCost);
|
||||
Invoke(artifacts, "BeginCharge");
|
||||
yield return null;
|
||||
Assert.That(visual.IsAuraVisible, Is.True);
|
||||
Assert.That(
|
||||
visual.AuraObject.GetComponent<SpriteRenderer>().sprite.texture.name,
|
||||
Does.Contain(effects[i].ToString()));
|
||||
int flashCountBefore = visual.FlashPlayCount;
|
||||
SetChargeAsFullyCharged(artifacts, definition);
|
||||
yield return null;
|
||||
Assert.That(visual.IsReadyAuraVisible, Is.True);
|
||||
Assert.That(
|
||||
visual.FlashPlayCount,
|
||||
Is.EqualTo(flashCountBefore + 1));
|
||||
yield return null;
|
||||
Assert.That(
|
||||
visual.FlashPlayCount,
|
||||
Is.EqualTo(flashCountBefore + 1));
|
||||
Invoke(artifacts, "CancelCharge");
|
||||
Assert.That(visual.IsReadyAuraVisible, Is.False);
|
||||
|
||||
if (effects[i] == ActiveArtifactEffect.ChainLightning)
|
||||
{
|
||||
director.DebugSpawnImmediate(1);
|
||||
yield return null;
|
||||
chainTarget = Object.FindAnyObjectByType<EnemyController>();
|
||||
Assert.That(chainTarget, Is.Not.Null);
|
||||
chainTarget.enabled = false;
|
||||
typeof(EnemyController)
|
||||
.GetProperty("CurrentHealth")
|
||||
?.SetValue(chainTarget, 100000f);
|
||||
Rigidbody2D targetBody = chainTarget.GetComponent<Rigidbody2D>();
|
||||
targetBody.position = playerBody.position + Vector2.right;
|
||||
Physics2D.SyncTransforms();
|
||||
}
|
||||
|
||||
EnsureGauge(artifacts, definition.NormalGaugeCost);
|
||||
int releaseCountBefore = visual.ReleasePlayCount;
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
yield return WaitForArtifactCompletion(artifacts, visual);
|
||||
Assert.That(
|
||||
visual.ReleasePlayCount,
|
||||
Is.EqualTo(releaseCountBefore + 1));
|
||||
|
||||
EnsureGauge(artifacts, definition.ChargedGaugeCost);
|
||||
releaseCountBefore = visual.ReleasePlayCount;
|
||||
Assert.That(artifacts.TryUseCurrent(true), Is.True);
|
||||
yield return WaitForArtifactCompletion(artifacts, visual);
|
||||
Assert.That(
|
||||
visual.ReleasePlayCount,
|
||||
Is.EqualTo(releaseCountBefore + 1));
|
||||
}
|
||||
|
||||
if (chainTarget != null)
|
||||
{
|
||||
Object.Destroy(chainTarget.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private Keyboard BeginVirtualKeyboardInput()
|
||||
{
|
||||
inputSettingsCaptured = true;
|
||||
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
|
||||
previousEditorInputBehavior =
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode;
|
||||
previousRunInBackground = Application.runInBackground;
|
||||
InputSystem.settings.backgroundBehavior =
|
||||
InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
Application.runInBackground = true;
|
||||
virtualKeyboard = InputSystem.AddDevice<Keyboard>();
|
||||
virtualKeyboard.MakeCurrent();
|
||||
virtualKeyboardInputActive = true;
|
||||
return virtualKeyboard;
|
||||
}
|
||||
|
||||
private static IEnumerator PressA(Keyboard keyboard)
|
||||
{
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A));
|
||||
yield return null;
|
||||
Assert.That(keyboard.aKey.isPressed, Is.True);
|
||||
}
|
||||
|
||||
private static IEnumerator ReleaseA(Keyboard keyboard)
|
||||
{
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(keyboard.aKey.isPressed, Is.False);
|
||||
}
|
||||
|
||||
private static void DisableCombatActors()
|
||||
{
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
director.enabled = false;
|
||||
}
|
||||
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetChargeAsFullyCharged(
|
||||
ActiveArtifactController artifacts,
|
||||
ActiveArtifactDefinition definition)
|
||||
{
|
||||
typeof(ActiveArtifactController)
|
||||
.GetField("chargeStartedAt", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.SetValue(
|
||||
artifacts,
|
||||
Time.time - definition.ChargeDuration - 0.01f);
|
||||
}
|
||||
|
||||
private static void Invoke(object target, string method)
|
||||
{
|
||||
target.GetType()
|
||||
.GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.Invoke(target, null);
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static IEnumerator WaitForArtifactCompletion(
|
||||
ActiveArtifactController artifacts,
|
||||
ArtifactChargeVisual visual)
|
||||
{
|
||||
DashController dash = artifacts.GetComponent<DashController>();
|
||||
float scaledDeadline = Time.time + 2.5f;
|
||||
float realtimeDeadline = Time.realtimeSinceStartup + 3f;
|
||||
while ((artifacts.IsExecutingArtifact
|
||||
|| (dash != null && dash.IsDashing)
|
||||
|| visual.IsReleaseVisible)
|
||||
&& Time.time < scaledDeadline
|
||||
&& Time.realtimeSinceStartup < realtimeDeadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(artifacts.IsExecutingArtifact, Is.False);
|
||||
if (dash != null)
|
||||
{
|
||||
Assert.That(dash.IsDashing, Is.False);
|
||||
}
|
||||
Assert.That(visual.IsReleaseVisible, Is.False);
|
||||
}
|
||||
|
||||
private static ActiveArtifactDefinition SelectArtifact(
|
||||
ActiveArtifactController artifacts,
|
||||
ActiveArtifactEffect effect)
|
||||
{
|
||||
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
|
||||
{
|
||||
if (artifacts.CurrentArtifact != null
|
||||
&& artifacts.CurrentArtifact.Effect == effect)
|
||||
{
|
||||
return artifacts.CurrentArtifact;
|
||||
}
|
||||
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
}
|
||||
|
||||
Assert.Fail($"Artifact {effect} was not found in the debug catalog.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void EnsureGauge(
|
||||
ActiveArtifactController artifacts,
|
||||
float required)
|
||||
{
|
||||
while (artifacts.CurrentGauge < required)
|
||||
{
|
||||
artifacts.AddMovementCharge(1f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetGauge(
|
||||
ActiveArtifactController artifacts,
|
||||
float value)
|
||||
{
|
||||
typeof(ActiveArtifactController)
|
||||
.GetField(
|
||||
"<CurrentGauge>k__BackingField",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.SetValue(artifacts, Mathf.Max(0f, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4883bb10a234690bf7f37d25b20e25f
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e4c1f9a7b1d4f6c8e3a5d0b9c2f7e81
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Collections;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class ArtifactImpactFeedbackPlayModeTests
|
||||
{
|
||||
private GameObject feedbackObject;
|
||||
private GameObject targetObject;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
feedbackObject = new GameObject("Artifact Feedback Test Host");
|
||||
feedbackObject.AddComponent<SpriteRenderer>();
|
||||
feedbackObject.AddComponent<CombatFeedback>();
|
||||
targetObject = new GameObject("Artifact Feedback Test Target");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (targetObject != null)
|
||||
{
|
||||
Object.Destroy(targetObject);
|
||||
}
|
||||
if (feedbackObject != null)
|
||||
{
|
||||
Object.Destroy(feedbackObject);
|
||||
}
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SixArtifacts_RenderNormalAndChargedContacts_AndCleanUp()
|
||||
{
|
||||
CombatFeedback feedback = feedbackObject.GetComponent<CombatFeedback>();
|
||||
ActiveArtifactEffect[] effects =
|
||||
{
|
||||
ActiveArtifactEffect.Dash,
|
||||
ActiveArtifactEffect.Pulse,
|
||||
ActiveArtifactEffect.Phoenix,
|
||||
ActiveArtifactEffect.Cyclone,
|
||||
ActiveArtifactEffect.ThunderCrash,
|
||||
ActiveArtifactEffect.ChainLightning,
|
||||
};
|
||||
|
||||
for (int i = 0; i < effects.Length; i++)
|
||||
{
|
||||
ActiveArtifactEffect effect = effects[i];
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
feedbackObject,
|
||||
targetObject,
|
||||
false,
|
||||
HitSide.Front,
|
||||
0f,
|
||||
Vector2.up,
|
||||
0f,
|
||||
new Vector2(i, 0f),
|
||||
isArtifactHit: true,
|
||||
artifactEffect: effect,
|
||||
isChargedArtifact: false));
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
feedbackObject,
|
||||
targetObject,
|
||||
false,
|
||||
HitSide.Front,
|
||||
0f,
|
||||
Vector2.up,
|
||||
0f,
|
||||
new Vector2(i, 0.5f),
|
||||
showsHitFeedback: false,
|
||||
isArtifactHit: true,
|
||||
artifactEffect: effect,
|
||||
isChargedArtifact: true));
|
||||
|
||||
string effectName = effect switch
|
||||
{
|
||||
ActiveArtifactEffect.Phoenix => "SearingRay",
|
||||
ActiveArtifactEffect.ChainLightning => "Arc",
|
||||
_ => effect.ToString(),
|
||||
};
|
||||
Assert.That(
|
||||
GameObject.Find($"Artifact Impact {effectName} Normal"),
|
||||
Is.Not.Null);
|
||||
Assert.That(
|
||||
GameObject.Find($"Artifact Impact {effectName} Charged"),
|
||||
Is.Not.Null);
|
||||
}
|
||||
|
||||
Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.EqualTo(12));
|
||||
Assert.That(GameObject.Find("Hit Impact"), Is.Null);
|
||||
Assert.That(GameObject.Find("Attack Slash"), Is.Null);
|
||||
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSecondsRealtime(0.25f);
|
||||
|
||||
Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero);
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a7a670d02624028bb6e6417e4cef840
|
||||
labels:
|
||||
- Test
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,355 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class ArtifactSwitchEventPlayModeTests
|
||||
{
|
||||
private GameObject testPlayer;
|
||||
private ActiveArtifactController artifacts;
|
||||
private ActiveArtifactDefinition firstArtifact;
|
||||
private ActiveArtifactDefinition secondArtifact;
|
||||
private Keyboard virtualKeyboard;
|
||||
private bool virtualKeyboardInputActive;
|
||||
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
|
||||
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
|
||||
private bool inputSettingsCaptured;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
if (virtualKeyboardInputActive
|
||||
&& virtualKeyboard != null
|
||||
&& virtualKeyboard.added)
|
||||
{
|
||||
InputSystem.QueueStateEvent(virtualKeyboard, new KeyboardState());
|
||||
InputSystem.RemoveDevice(virtualKeyboard);
|
||||
}
|
||||
|
||||
if (inputSettingsCaptured)
|
||||
{
|
||||
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
previousEditorInputBehavior;
|
||||
}
|
||||
|
||||
if (testPlayer != null)
|
||||
{
|
||||
Object.Destroy(testPlayer);
|
||||
}
|
||||
|
||||
if (firstArtifact != null)
|
||||
{
|
||||
Object.Destroy(firstArtifact);
|
||||
}
|
||||
|
||||
if (secondArtifact != null)
|
||||
{
|
||||
Object.Destroy(secondArtifact);
|
||||
}
|
||||
|
||||
virtualKeyboard = null;
|
||||
virtualKeyboardInputActive = false;
|
||||
inputSettingsCaptured = false;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ArtifactSwitchedEvent_OnlyFiresOnceAfterSuccessfulSwitch()
|
||||
{
|
||||
RunManager previousRunManager = RunManager.Instance;
|
||||
if (previousRunManager == null)
|
||||
{
|
||||
previousRunManager = null;
|
||||
}
|
||||
bool previousRunManagerEnabled = previousRunManager != null
|
||||
&& previousRunManager.enabled;
|
||||
if (previousRunManager != null)
|
||||
{
|
||||
previousRunManager.enabled = false;
|
||||
}
|
||||
|
||||
SetRunManagerInstanceForTest(null);
|
||||
Time.timeScale = 1f;
|
||||
ActiveArtifactController[] existingControllers =
|
||||
Object.FindObjectsByType<ActiveArtifactController>(
|
||||
FindObjectsSortMode.None);
|
||||
bool[] previousControllerStates = new bool[existingControllers.Length];
|
||||
for (int i = 0; i < existingControllers.Length; i++)
|
||||
{
|
||||
previousControllerStates[i] = existingControllers[i].enabled;
|
||||
existingControllers[i].enabled = false;
|
||||
}
|
||||
|
||||
Keyboard keyboard = null;
|
||||
try
|
||||
{
|
||||
testPlayer = new GameObject("Artifact Switch Event Test Player");
|
||||
testPlayer.AddComponent<PlayerStats>();
|
||||
artifacts = testPlayer.AddComponent<ActiveArtifactController>();
|
||||
firstArtifact = CreateArtifact(
|
||||
"switch-event-dash",
|
||||
ActiveArtifactEffect.Dash);
|
||||
secondArtifact = CreateArtifact(
|
||||
"switch-event-pulse",
|
||||
ActiveArtifactEffect.Pulse);
|
||||
|
||||
int switchCount = 0;
|
||||
int chargeStartCount = 0;
|
||||
ActiveArtifactDefinition eventDefinition = null;
|
||||
ActiveArtifactDefinition selectedDuringEvent = null;
|
||||
artifacts.OnArtifactSwitched += definition =>
|
||||
{
|
||||
switchCount++;
|
||||
eventDefinition = definition;
|
||||
selectedDuringEvent = artifacts.CurrentArtifact;
|
||||
};
|
||||
artifacts.OnArtifactChargeStarted += _ => chargeStartCount++;
|
||||
int useCount = 0;
|
||||
artifacts.OnArtifactUseSucceeded += (_, _) => useCount++;
|
||||
|
||||
Assert.That(artifacts.TryAddArtifact(firstArtifact), Is.True);
|
||||
Assert.That(artifacts.SelectNext(), Is.False);
|
||||
Assert.That(switchCount, Is.Zero,
|
||||
"Acquisition and a failed switch must not emit the switch event.");
|
||||
|
||||
artifacts.AddMovementCharge(1f);
|
||||
artifacts.AddMovementCharge(49f);
|
||||
Assert.That(artifacts.CurrentGauge, Is.GreaterThan(0f));
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(100f));
|
||||
Assert.That(switchCount, Is.Zero,
|
||||
"Gauge changes must not emit the switch event.");
|
||||
|
||||
keyboard = BeginVirtualKeyboardInput();
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A));
|
||||
yield return null;
|
||||
Assert.That(artifacts.IsCharging, Is.True);
|
||||
Assert.That(chargeStartCount, Is.EqualTo(1));
|
||||
Assert.That(artifacts.SelectNext(), Is.False);
|
||||
Assert.That(switchCount, Is.Zero,
|
||||
"A one-artifact inventory cannot switch and keeps charging.");
|
||||
Assert.That(artifacts.IsCharging, Is.True,
|
||||
"A failed single-slot switch must preserve the charge.");
|
||||
|
||||
Assert.That(artifacts.TryAddArtifact(secondArtifact), Is.True);
|
||||
Assert.That(switchCount, Is.Zero,
|
||||
"Adding another owned artifact must not be reported as switching.");
|
||||
yield return new WaitForSeconds(firstArtifact.ChargeDuration * 0.5f);
|
||||
Assert.That(artifacts.ChargeProgress, Is.GreaterThan(0f).And.LessThan(1f));
|
||||
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
Assert.That(artifacts.IsCharging, Is.False,
|
||||
"A successful switch must cancel a partial charge.");
|
||||
Assert.That(switchCount, Is.EqualTo(1));
|
||||
Assert.That(eventDefinition, Is.SameAs(secondArtifact));
|
||||
Assert.That(selectedDuringEvent, Is.SameAs(secondArtifact),
|
||||
"The event must observe the newly selected artifact.");
|
||||
Assert.That(artifacts.CurrentArtifact, Is.SameAs(secondArtifact));
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(100f),
|
||||
"Switching during a partial charge must not spend gauge.");
|
||||
Assert.That(useCount, Is.Zero,
|
||||
"Switching during a partial charge must not activate an artifact.");
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(useCount, Is.Zero,
|
||||
"Releasing the held charge after switching must not activate either artifact.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (keyboard != null && keyboard.added)
|
||||
{
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
}
|
||||
|
||||
for (int i = 0; i < existingControllers.Length; i++)
|
||||
{
|
||||
if (existingControllers[i] != null)
|
||||
{
|
||||
existingControllers[i].enabled = previousControllerStates[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (previousRunManager != null)
|
||||
{
|
||||
previousRunManager.enabled = previousRunManagerEnabled;
|
||||
}
|
||||
SetRunManagerInstanceForTest(previousRunManager);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator FullyChargedSwitch_CancelsWithoutActivationOrGaugeCost()
|
||||
{
|
||||
testPlayer = new GameObject("Fully Charged Artifact Switch Test Player");
|
||||
testPlayer.AddComponent<PlayerStats>();
|
||||
artifacts = testPlayer.AddComponent<ActiveArtifactController>();
|
||||
firstArtifact = CreateArtifact(
|
||||
"fully-charged-switch-pulse",
|
||||
ActiveArtifactEffect.Pulse);
|
||||
secondArtifact = CreateArtifact(
|
||||
"fully-charged-switch-cyclone",
|
||||
ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(artifacts.TryAddArtifact(firstArtifact), Is.True);
|
||||
Assert.That(artifacts.TryAddArtifact(secondArtifact), Is.True);
|
||||
artifacts.AddMovementCharge(50f);
|
||||
float gaugeBeforeSwitch = artifacts.CurrentGauge;
|
||||
int useCount = 0;
|
||||
artifacts.OnArtifactUseSucceeded += (_, _) => useCount++;
|
||||
|
||||
InvokePrivate(artifacts, "BeginCharge");
|
||||
typeof(ActiveArtifactController)
|
||||
.GetField("chargeStartedAt", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.SetValue(
|
||||
artifacts,
|
||||
Time.time - firstArtifact.ChargeDuration - 0.01f);
|
||||
Assert.That(artifacts.IsFullyCharged, Is.True);
|
||||
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
Assert.That(artifacts.CurrentArtifact, Is.SameAs(secondArtifact));
|
||||
Assert.That(artifacts.IsCharging, Is.False);
|
||||
Assert.That(artifacts.IsExecutingArtifact, Is.False);
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBeforeSwitch));
|
||||
Assert.That(useCount, Is.Zero);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SWhileHoldingA_CancelsChargeAndReleaseWaitsForFreshPress()
|
||||
{
|
||||
testPlayer = new GameObject("Held A Artifact Switch Test Player");
|
||||
testPlayer.AddComponent<PlayerStats>();
|
||||
artifacts = testPlayer.AddComponent<ActiveArtifactController>();
|
||||
firstArtifact = CreateArtifact(
|
||||
"held-a-switch-pulse",
|
||||
ActiveArtifactEffect.Pulse);
|
||||
secondArtifact = CreateArtifact(
|
||||
"held-a-switch-cyclone",
|
||||
ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(artifacts.TryAddArtifact(firstArtifact), Is.True);
|
||||
Assert.That(artifacts.TryAddArtifact(secondArtifact), Is.True);
|
||||
artifacts.AddMovementCharge(50f);
|
||||
float gaugeBeforeSwitch = artifacts.CurrentGauge;
|
||||
int chargeStartCount = 0;
|
||||
int useCount = 0;
|
||||
artifacts.OnArtifactChargeStarted += _ => chargeStartCount++;
|
||||
artifacts.OnArtifactUseSucceeded += (_, _) => useCount++;
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A));
|
||||
yield return null;
|
||||
Assert.That(artifacts.IsCharging, Is.True);
|
||||
Assert.That(chargeStartCount, Is.EqualTo(1));
|
||||
yield return new WaitForSeconds(firstArtifact.ChargeDuration + 0.05f);
|
||||
Assert.That(artifacts.IsFullyCharged, Is.True);
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A, Key.S));
|
||||
yield return null;
|
||||
Assert.That(keyboard.aKey.isPressed, Is.True);
|
||||
Assert.That(keyboard.sKey.isPressed, Is.True);
|
||||
Assert.That(artifacts.CurrentArtifact, Is.SameAs(secondArtifact));
|
||||
Assert.That(artifacts.IsCharging, Is.False,
|
||||
"S must cancel a fully charged artifact while A remains held.");
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBeforeSwitch));
|
||||
Assert.That(useCount, Is.Zero);
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(artifacts.IsCharging, Is.False);
|
||||
Assert.That(artifacts.IsExecutingArtifact, Is.False);
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBeforeSwitch));
|
||||
Assert.That(useCount, Is.Zero,
|
||||
"Releasing the A held through the switch must not activate either artifact.");
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A));
|
||||
yield return null;
|
||||
Assert.That(artifacts.IsCharging, Is.True,
|
||||
"A fresh press after switching may begin a new charge.");
|
||||
Assert.That(chargeStartCount, Is.EqualTo(2));
|
||||
Assert.That(artifacts.CurrentArtifact, Is.SameAs(secondArtifact));
|
||||
|
||||
InvokePrivate(artifacts, "CancelCharge");
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(useCount, Is.Zero);
|
||||
Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBeforeSwitch));
|
||||
}
|
||||
|
||||
private static ActiveArtifactDefinition CreateArtifact(
|
||||
string id,
|
||||
ActiveArtifactEffect effect)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
|
||||
definition.Configure(
|
||||
id,
|
||||
id,
|
||||
"",
|
||||
effect,
|
||||
DamageTag.Collision,
|
||||
Color.white,
|
||||
Color.white,
|
||||
25f,
|
||||
60f,
|
||||
0.8f,
|
||||
15f,
|
||||
30f,
|
||||
1.25f,
|
||||
2f,
|
||||
1.5f,
|
||||
3f);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private Keyboard BeginVirtualKeyboardInput()
|
||||
{
|
||||
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
|
||||
previousEditorInputBehavior =
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode;
|
||||
inputSettingsCaptured = true;
|
||||
InputSystem.settings.backgroundBehavior =
|
||||
InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
virtualKeyboard = InputSystem.AddDevice<Keyboard>();
|
||||
virtualKeyboard.MakeCurrent();
|
||||
virtualKeyboardInputActive = true;
|
||||
return virtualKeyboard;
|
||||
}
|
||||
|
||||
private static void InvokePrivate(
|
||||
ActiveArtifactController target,
|
||||
string methodName)
|
||||
{
|
||||
MethodInfo method = typeof(ActiveArtifactController).GetMethod(
|
||||
methodName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(method, Is.Not.Null, $"Could not find {methodName}.");
|
||||
method.Invoke(target, null);
|
||||
}
|
||||
|
||||
private static void SetRunManagerInstanceForTest(RunManager instance)
|
||||
{
|
||||
FieldInfo instanceField = typeof(RunManager).GetField(
|
||||
"<Instance>k__BackingField",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
Assert.That(instanceField, Is.Not.Null);
|
||||
instanceField.SetValue(null, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d57d748b42c0a94788c4a4c35e2fde7
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "BumpCombat.PlayModeTests",
|
||||
"rootNamespace": "BumpCombat.PlayModeTests",
|
||||
"references": [
|
||||
"BumpCombat.Runtime",
|
||||
"Unity.InputSystem",
|
||||
"UnityEngine.UI"
|
||||
],
|
||||
"optionalUnityReferences": [
|
||||
"TestAssemblies"
|
||||
],
|
||||
"autoReferenced": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 73bdd9ecb0fe2db479136cd966185223
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d1e8b7c2a5f49e0936c0b7a1d8f3e62
|
||||
@@ -0,0 +1,457 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.UI;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class FiniteArenaPlayModeTests
|
||||
{
|
||||
[TearDown]
|
||||
public void Cleanup()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PauseFreezesCameraFollowUntilPlayResumes()
|
||||
{
|
||||
yield return LoadAndStartRun();
|
||||
RunManager runManager = RunManager.Instance;
|
||||
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ArenaCameraFollow follow = Object.FindAnyObjectByType<ArenaCameraFollow>();
|
||||
ArenaBounds bounds = Object.FindAnyObjectByType<ArenaBounds>();
|
||||
Assert.That(player, Is.Not.Null);
|
||||
Assert.That(follow, Is.Not.Null);
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
|
||||
player.TryReposition(bounds.SharedActorClampHalfExtents);
|
||||
yield return null;
|
||||
Assert.That(runManager.TryOpenPause(), Is.True);
|
||||
Vector2 pausedCenter = follow.CurrentCenter;
|
||||
yield return new WaitForSecondsRealtime(0.3f);
|
||||
Assert.That(follow.CurrentCenter, Is.EqualTo(pausedCenter));
|
||||
|
||||
Assert.That(runManager.ClosePause(), Is.True);
|
||||
yield return new WaitForSecondsRealtime(0.8f);
|
||||
Assert.That(follow.CurrentCenter.x, Is.GreaterThan(0.1f));
|
||||
Assert.That(follow.CurrentCenter.y, Is.GreaterThan(0.1f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator RangedEnemyAtCornerApproachesBeforeAttackStart()
|
||||
{
|
||||
yield return LoadAndStartRun();
|
||||
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
director.enabled = false;
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
PlayerHealth playerHealth = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
Camera camera = Camera.main;
|
||||
ArenaCameraFollow follow = Object.FindAnyObjectByType<ArenaCameraFollow>();
|
||||
ArenaBounds bounds = Object.FindAnyObjectByType<ArenaBounds>();
|
||||
Assert.That(playerHealth, Is.Not.Null);
|
||||
Assert.That(camera, Is.Not.Null);
|
||||
Assert.That(follow, Is.Not.Null);
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
playerHealth.GrantInvulnerability(10f);
|
||||
|
||||
yield return DestroyActiveEnemies();
|
||||
camera.aspect = 16f / 9f;
|
||||
EnemyController rangedPrefab = FindRangedPrefab(director);
|
||||
Assert.That(rangedPrefab, Is.Not.Null, "A ranged prefab is required for this regression.");
|
||||
Vector2 halfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2[] edgePositions =
|
||||
{
|
||||
new(halfExtents.x, 0f),
|
||||
new(-halfExtents.x, 0f),
|
||||
new(0f, halfExtents.y),
|
||||
new(0f, -halfExtents.y),
|
||||
};
|
||||
for (int edgeIndex = 0; edgeIndex < edgePositions.Length; edgeIndex++)
|
||||
{
|
||||
Vector2 edgePosition = edgePositions[edgeIndex];
|
||||
player.TryReposition(edgePosition);
|
||||
// Let the real follow path settle before the enemy is created
|
||||
// so the visible gate is measured from that edge camera.
|
||||
yield return new WaitForSecondsRealtime(0.9f);
|
||||
|
||||
EnemyController ranged = Object.Instantiate(
|
||||
rangedPrefab,
|
||||
edgePosition,
|
||||
Quaternion.identity);
|
||||
yield return null;
|
||||
float gateDeadline = Time.realtimeSinceStartup + 3f;
|
||||
while (!ranged.IsRangedAttackStartAllowed
|
||||
&& Time.realtimeSinceStartup < gateDeadline)
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
}
|
||||
|
||||
Vector2 position = ranged.GetComponent<Rigidbody2D>().position;
|
||||
Assert.That(
|
||||
ranged.IsRangedAttackStartAllowed,
|
||||
Is.True,
|
||||
$"Ranged enemy remained outside its attack gate at edge={edgeIndex}, "
|
||||
+ $"position={position}; camera={camera.transform.position}.");
|
||||
Vector2 rangedGateHalfExtents = bounds.GetReachableHalfExtents(0.75f);
|
||||
Assert.That(
|
||||
Mathf.Abs(position.x),
|
||||
Is.LessThanOrEqualTo(rangedGateHalfExtents.x + 0.001f));
|
||||
Assert.That(
|
||||
Mathf.Abs(position.y),
|
||||
Is.LessThanOrEqualTo(rangedGateHalfExtents.y + 0.001f));
|
||||
|
||||
float attackDeadline = Time.realtimeSinceStartup + 1.5f;
|
||||
while (ranged.State != EnemyState.Warning
|
||||
&& ranged.State != EnemyState.Active
|
||||
&& Time.realtimeSinceStartup < attackDeadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
ranged.State == EnemyState.Warning
|
||||
|| ranged.State == EnemyState.Active,
|
||||
Is.True,
|
||||
$"Ranged enemy entered the interior gate but did not start an attack "
|
||||
+ $"(edge={edgeIndex}, state={ranged.State}, position={position}).");
|
||||
Object.Destroy(ranged.gameObject);
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LargeHorizontalMeleeAtFiniteArenaEdges_ReachesActiveAttack()
|
||||
{
|
||||
yield return LoadAndStartRun();
|
||||
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
director.enabled = false;
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
PlayerHealth playerHealth = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
Rigidbody2D playerBody = player?.GetComponent<Rigidbody2D>();
|
||||
ArenaBounds bounds = Object.FindAnyObjectByType<ArenaBounds>();
|
||||
Assert.That(player, Is.Not.Null);
|
||||
Assert.That(playerHealth, Is.Not.Null);
|
||||
Assert.That(playerBody, Is.Not.Null);
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
playerHealth.GrantInvulnerability(60f);
|
||||
|
||||
yield return DestroyActiveEnemies();
|
||||
|
||||
Vector2 halfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2[] playerPositions =
|
||||
{
|
||||
new(0f, -halfExtents.y),
|
||||
new(0f, halfExtents.y),
|
||||
new(-halfExtents.x, 0f),
|
||||
new(halfExtents.x, 0f),
|
||||
new(-halfExtents.x, -halfExtents.y),
|
||||
new(halfExtents.x, -halfExtents.y),
|
||||
new(-halfExtents.x, halfExtents.y),
|
||||
new(halfExtents.x, halfExtents.y),
|
||||
};
|
||||
EnemyKind[] kinds =
|
||||
{
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
EnemyKind.GreatswordSkeleton,
|
||||
};
|
||||
RunTimedEvent[] roles =
|
||||
{
|
||||
RunTimedEvent.Elite,
|
||||
RunTimedEvent.MidBoss,
|
||||
};
|
||||
float[] scaleMultipliers = { 1.25f, 1.6f };
|
||||
|
||||
for (int kindIndex = 0; kindIndex < kinds.Length; kindIndex++)
|
||||
{
|
||||
EnemyController prefab = FindCatalogPrefab(
|
||||
director,
|
||||
kinds[kindIndex]);
|
||||
Assert.That(
|
||||
prefab,
|
||||
Is.Not.Null,
|
||||
$"Missing edge melee prefab for {kinds[kindIndex]}.");
|
||||
|
||||
for (int positionIndex = 0;
|
||||
positionIndex < playerPositions.Length;
|
||||
positionIndex++)
|
||||
{
|
||||
Vector2 playerPosition = playerPositions[positionIndex];
|
||||
Assert.That(player.TryReposition(playerPosition), Is.True);
|
||||
yield return new WaitForFixedUpdate();
|
||||
playerHealth.GrantInvulnerability(10f);
|
||||
|
||||
Vector2 inward = new Vector2(
|
||||
Mathf.Sign(-playerPosition.x),
|
||||
Mathf.Sign(-playerPosition.y));
|
||||
if (inward.sqrMagnitude <= 0.0001f)
|
||||
{
|
||||
inward = Vector2.up;
|
||||
}
|
||||
inward.Normalize();
|
||||
EnemyController enemy = Object.Instantiate(
|
||||
prefab,
|
||||
playerPosition + inward * 3f,
|
||||
Quaternion.identity);
|
||||
enemy.ConfigureRunEventEnemy(
|
||||
roles[kindIndex],
|
||||
CreateEdgeEventTuning(
|
||||
kinds[kindIndex],
|
||||
scaleMultipliers[kindIndex]));
|
||||
yield return null;
|
||||
|
||||
float warningDeadline = Time.realtimeSinceStartup + 5f;
|
||||
while (enemy.State != EnemyState.Warning
|
||||
&& enemy.State != EnemyState.Active
|
||||
&& Time.realtimeSinceStartup < warningDeadline)
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
enemy.State == EnemyState.Warning
|
||||
|| enemy.State == EnemyState.Active,
|
||||
Is.True,
|
||||
$"{kinds[kindIndex]} stalled before attack at position "
|
||||
+ $"{playerPosition}; enemy={enemy.GroundAnchorPosition}, "
|
||||
+ $"range={enemy.AttackRange}.");
|
||||
|
||||
float activeDeadline = Time.realtimeSinceStartup + 2f;
|
||||
while (enemy.State != EnemyState.Active
|
||||
&& Time.realtimeSinceStartup < activeDeadline)
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
enemy.State,
|
||||
Is.EqualTo(EnemyState.Active),
|
||||
$"{kinds[kindIndex]} did not reach its active hit window "
|
||||
+ $"at position {playerPosition}.");
|
||||
Assert.That(
|
||||
Vector2.Distance(playerBody.position, playerPosition),
|
||||
Is.LessThan(0.02f),
|
||||
"The stationary edge target moved before the melee hit window.");
|
||||
EnemyAttack attack = enemy.GetComponent<EnemyAttack>();
|
||||
Assert.That(attack, Is.Not.Null);
|
||||
Assert.That(attack.IsContactWindowActive(0f), Is.True);
|
||||
|
||||
Object.Destroy(enemy.gameObject);
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SpawnSampling_StaysReachableSafeAndOutsideVisibleView()
|
||||
{
|
||||
yield return LoadAndStartRun();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
director.enabled = false;
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
ArenaBounds bounds = Object.FindAnyObjectByType<ArenaBounds>();
|
||||
Camera camera = Camera.main;
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
Assert.That(camera, Is.Not.Null);
|
||||
camera.aspect = 16f / 9f;
|
||||
|
||||
Vector2 halfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2[] corners =
|
||||
{
|
||||
Vector2.zero,
|
||||
new Vector2(-halfExtents.x, halfExtents.y),
|
||||
new Vector2(halfExtents.x, halfExtents.y),
|
||||
new Vector2(halfExtents.x, -halfExtents.y),
|
||||
new Vector2(-halfExtents.x, -halfExtents.y),
|
||||
};
|
||||
for (int cornerIndex = 0; cornerIndex < corners.Length; cornerIndex++)
|
||||
{
|
||||
Assert.That(player.TryReposition(corners[cornerIndex]), Is.True);
|
||||
yield return new WaitForSecondsRealtime(0.35f);
|
||||
Bounds expandedVisible = bounds.GetCameraVisibleBounds(camera);
|
||||
expandedVisible.Expand(1f);
|
||||
for (int sample = 0; sample < 16; sample++)
|
||||
{
|
||||
Vector2 position = director.GetSpawnPositionForTests();
|
||||
Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True);
|
||||
Assert.That(
|
||||
Vector2.Distance(position, corners[cornerIndex]),
|
||||
Is.GreaterThanOrEqualTo(4f));
|
||||
bool insideExpandedVisible = position.x >= expandedVisible.min.x
|
||||
&& position.x <= expandedVisible.max.x
|
||||
&& position.y >= expandedVisible.min.y
|
||||
&& position.y <= expandedVisible.max.y;
|
||||
Assert.That(insideExpandedVisible, Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator DestroyActiveEnemies()
|
||||
{
|
||||
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None);
|
||||
for (int i = 0; i < enemies.Length; i++)
|
||||
{
|
||||
if (enemies[i] != null)
|
||||
{
|
||||
Object.Destroy(enemies[i].gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static EnemyController FindRangedPrefab(SpawnDirector director)
|
||||
{
|
||||
FieldInfo field = typeof(SpawnDirector).GetField(
|
||||
"enemyPrefabs",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
EnemyController[] prefabs = field?.GetValue(director) as EnemyController[];
|
||||
if (prefabs == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (int i = 0; i < prefabs.Length; i++)
|
||||
{
|
||||
if (prefabs[i] != null
|
||||
&& prefabs[i].Definition != null
|
||||
&& prefabs[i].Definition.IsRanged)
|
||||
{
|
||||
return prefabs[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EnemyController FindCatalogPrefab(
|
||||
SpawnDirector director,
|
||||
EnemyKind kind)
|
||||
{
|
||||
string[] fieldNames =
|
||||
{
|
||||
"enemyPrefabs",
|
||||
"elitePrefabs",
|
||||
"midBossPrefabs",
|
||||
"finalBossPrefabs",
|
||||
};
|
||||
for (int fieldIndex = 0; fieldIndex < fieldNames.Length; fieldIndex++)
|
||||
{
|
||||
FieldInfo field = typeof(SpawnDirector).GetField(
|
||||
fieldNames[fieldIndex],
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
EnemyController[] prefabs = field?.GetValue(director) as EnemyController[];
|
||||
if (prefabs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int prefabIndex = 0; prefabIndex < prefabs.Length; prefabIndex++)
|
||||
{
|
||||
EnemyController prefab = prefabs[prefabIndex];
|
||||
if (prefab != null
|
||||
&& prefab.Definition != null
|
||||
&& prefab.Definition.Kind == kind)
|
||||
{
|
||||
return prefab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RunEventEnemyTuning CreateEdgeEventTuning(
|
||||
EnemyKind kind,
|
||||
float scale)
|
||||
{
|
||||
return RunEventEnemyTuning.Create(
|
||||
kind,
|
||||
1f,
|
||||
0,
|
||||
scale,
|
||||
Color.white,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
false);
|
||||
}
|
||||
|
||||
private static IEnumerator LoadAndStartRun()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
RunManager runManager = RunManager.Instance;
|
||||
if (runManager.IsTitleScreen)
|
||||
{
|
||||
Assert.That(runManager.BeginRun(), Is.True);
|
||||
}
|
||||
|
||||
ArtifactRewardController reward =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(reward, Is.Not.Null);
|
||||
for (int selection = 0; selection < 3; selection++)
|
||||
{
|
||||
float deadline = Time.realtimeSinceStartup + 2f;
|
||||
while (!reward.DebugIsSelectionVisible
|
||||
&& Time.realtimeSinceStartup < deadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(reward.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(reward.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// The first artifact acquisition queues the tutorial modal. This
|
||||
// helper is used by gameplay tests, so acknowledge any queued
|
||||
// guidance before waiting on gameplay-time coroutines.
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
for (int tutorial = 0;
|
||||
tutorials != null && tutorials.DebugIsVisible && tutorial < 4;
|
||||
tutorial++)
|
||||
{
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
RunManager.GameplayInputEnabled,
|
||||
Is.True,
|
||||
"Finite arena tests require startup tutorial guidance to be closed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2afc62de8d7345ad8f0c35636e92f7a4
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class GameplayConstantsConsumptionPlayModeTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ExperienceOrb_UsesChangedPickupRadius()
|
||||
{
|
||||
ItemConstants constants = GameplayConstants.Current.Items;
|
||||
float originalPickupRadius = constants.ExperienceOrbPickupRadius;
|
||||
float originalMagnetSpeed = constants.ExperienceOrbMagnetSpeed;
|
||||
GameObject playerObject = new("Constants Orb Player");
|
||||
GameObject orbObject = new("Constants Orb");
|
||||
try
|
||||
{
|
||||
playerObject.AddComponent<PlayerStats>();
|
||||
playerObject.AddComponent<ExperienceSystem>();
|
||||
Rigidbody2D orbBody = orbObject.AddComponent<Rigidbody2D>();
|
||||
orbObject.AddComponent<CircleCollider2D>();
|
||||
ExperienceOrb orb = orbObject.AddComponent<ExperienceOrb>();
|
||||
orb.Initialize(1);
|
||||
playerObject.transform.position = Vector3.zero;
|
||||
orbBody.position = new Vector2(0.3f, 0f);
|
||||
orbBody.gravityScale = 0f;
|
||||
constants.ExperienceOrbPickupRadius = 0.2f;
|
||||
constants.ExperienceOrbMagnetSpeed = 0f;
|
||||
|
||||
yield return null;
|
||||
// ExperienceOrb resolves its target through the scene-wide
|
||||
// lookup in Start. Bind this fixture back to its own player
|
||||
// after Start so another scene player cannot make the radius
|
||||
// assertion depend on test order.
|
||||
SetPrivate(orb, "experienceSystem",
|
||||
playerObject.GetComponent<ExperienceSystem>());
|
||||
SetPrivate(orb, "player", playerObject.transform);
|
||||
yield return new WaitForFixedUpdate();
|
||||
Assert.That(orb, Is.Not.Null);
|
||||
Assert.That(GetPrivate<bool>(orb, "pickedUp"), Is.False);
|
||||
|
||||
constants.ExperienceOrbPickupRadius = 0.4f;
|
||||
yield return new WaitForFixedUpdate();
|
||||
Assert.That(GetPrivate<bool>(orb, "pickedUp"), Is.True);
|
||||
}
|
||||
finally
|
||||
{
|
||||
constants.ExperienceOrbPickupRadius = originalPickupRadius;
|
||||
constants.ExperienceOrbMagnetSpeed = originalMagnetSpeed;
|
||||
if (playerObject != null)
|
||||
{
|
||||
Object.Destroy(playerObject);
|
||||
}
|
||||
if (orbObject != null)
|
||||
{
|
||||
Object.Destroy(orbObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CombatFeedback_UsesChangedHeavyEffectLimit()
|
||||
{
|
||||
CombatFeedbackSettings constants = GameplayConstants.Current.CombatFeedback;
|
||||
int originalLimit = constants.MaxConcurrentHeavyEffects;
|
||||
GameObject feedbackObject = new("Constants Feedback Host");
|
||||
GameObject targetObject = new("Constants Feedback Target");
|
||||
try
|
||||
{
|
||||
constants.MaxConcurrentHeavyEffects = 2;
|
||||
feedbackObject.AddComponent<SpriteRenderer>();
|
||||
CombatFeedback feedback = feedbackObject.AddComponent<CombatFeedback>();
|
||||
yield return null;
|
||||
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
CombatEvents.RaiseEnemyStagger(targetObject, 1, 1, true);
|
||||
CombatEvents.RaiseLaunchResisted(targetObject);
|
||||
}
|
||||
|
||||
Assert.That(CombatFeedback.MaxConcurrentHeavyEffects, Is.EqualTo(2));
|
||||
Assert.That(feedback.ActiveHeavyEffectCount, Is.GreaterThan(0));
|
||||
Assert.That(feedback.ActiveHeavyEffectCount, Is.LessThanOrEqualTo(2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
constants.MaxConcurrentHeavyEffects = originalLimit;
|
||||
if (targetObject != null)
|
||||
{
|
||||
Object.Destroy(targetObject);
|
||||
}
|
||||
if (feedbackObject != null)
|
||||
{
|
||||
Object.Destroy(feedbackObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static T GetPrivate<T>(object target, string fieldName)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(fieldName, NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null, fieldName);
|
||||
return (T)field.GetValue(target);
|
||||
}
|
||||
|
||||
private static void SetPrivate(object target, string fieldName, object value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(fieldName, NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null, fieldName);
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af2910badbce4ad193a494dea95337af
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.UI;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class GuardSystemPlayModeTests
|
||||
{
|
||||
private Keyboard virtualKeyboard;
|
||||
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
|
||||
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
|
||||
private bool previousRunInBackground;
|
||||
private bool inputSettingsCaptured;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (virtualKeyboard != null && virtualKeyboard.added)
|
||||
{
|
||||
InputSystem.QueueStateEvent(virtualKeyboard, new KeyboardState());
|
||||
InputSystem.RemoveDevice(virtualKeyboard);
|
||||
}
|
||||
|
||||
virtualKeyboard = null;
|
||||
if (inputSettingsCaptured)
|
||||
{
|
||||
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
previousEditorInputBehavior;
|
||||
Application.runInBackground = previousRunInBackground;
|
||||
inputSettingsCaptured = false;
|
||||
}
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DInput_ActivatesGuardAndBlocksDamageWithoutHurtOrKnockback()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerHealth health = FindHealth();
|
||||
float startingHealth = health.CurrentHealth;
|
||||
bool damageEventRaised = false;
|
||||
health.OnDamageTaken += _ => damageEventRaised = true;
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D));
|
||||
yield return null;
|
||||
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
Assert.That(health.GuardCooldownRemaining, Is.EqualTo(10f).Within(0.05f));
|
||||
Assert.That(health.GuardCooldownNormalized, Is.EqualTo(1f).Within(0.01f));
|
||||
Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0f).Within(0.01f));
|
||||
Assert.That(
|
||||
health.TryTakeDamage(25f, Vector2.right, 1.1f),
|
||||
Is.False);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth));
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(health.GetComponent<PlayerController>().IsDamageKnockbackActive,
|
||||
Is.False);
|
||||
Assert.That(damageEventRaised, Is.False);
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.LessThan(startingHealth));
|
||||
Assert.That(health.IsHurtMovementLocked, Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_DefaultDurationRemainsActiveAtPointSixSecondsThenExpires()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
Assert.That(health.GuardDuration,
|
||||
Is.EqualTo(PlayerHealth.DefaultGuardDuration));
|
||||
Assert.That(health.GuardDuration, Is.EqualTo(1f));
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
|
||||
yield return new WaitForSeconds(0.6f);
|
||||
Assert.That(health.IsGuarding, Is.True,
|
||||
"The default one-second guard must still be active after 0.6 seconds.");
|
||||
|
||||
yield return new WaitForSeconds(health.GuardDuration - 0.6f + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False,
|
||||
"The default guard must expire after its one-second duration.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DInput_ActivatesBeforeDamageInTheSameInputUpdate()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerHealth health = FindHealth();
|
||||
float startingHealth = health.CurrentHealth;
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D));
|
||||
InputSystem.Update();
|
||||
|
||||
Assert.That(
|
||||
health.TryTakeDamage(25f, Vector2.right, 1.1f),
|
||||
Is.False,
|
||||
"A same-frame attack must see D's processed input edge before health.Update runs.");
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth));
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(health.GetComponent<PlayerController>().IsDamageKnockbackActive,
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GuardImpactSignal_OnlyFiresForPositiveGuardBlockedDamage()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
int guardBlockedCount = 0;
|
||||
int guardImpactCount = 0;
|
||||
Vector2 impactDirection = Vector2.zero;
|
||||
float artifactGaugeBefore =
|
||||
health.GetComponent<ActiveArtifactController>().CurrentGauge;
|
||||
health.OnGuardAttackBlocked += _ => guardBlockedCount++;
|
||||
health.OnGuardImpact += direction =>
|
||||
{
|
||||
guardImpactCount++;
|
||||
impactDirection = direction;
|
||||
};
|
||||
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(
|
||||
health.GuardSuccessCooldownRechargePercent,
|
||||
Is.EqualTo(PlayerHealth.DefaultGuardSuccessCooldownRechargePercent));
|
||||
Assert.That(health.TryTakeDamage(0f, Vector2.left, 1.1f), Is.False);
|
||||
Assert.That(guardBlockedCount, Is.Zero);
|
||||
Assert.That(guardImpactCount, Is.Zero);
|
||||
Assert.That(health.GuardCooldownRemaining,
|
||||
Is.EqualTo(health.GuardCooldownDuration).Within(0.05f));
|
||||
|
||||
Assert.That(
|
||||
health.TryTakeDamage(25f, new Vector2(3f, 4f), 1.1f),
|
||||
Is.False);
|
||||
Assert.That(guardBlockedCount, Is.EqualTo(1));
|
||||
Assert.That(guardImpactCount, Is.EqualTo(1));
|
||||
Assert.That(impactDirection, Is.EqualTo(new Vector2(0.6f, 0.8f)));
|
||||
float cooldownAfterFirstBlock = health.GuardCooldownRemaining;
|
||||
Assert.That(cooldownAfterFirstBlock,
|
||||
Is.EqualTo(health.GuardCooldownDuration * 0.3f).Within(0.05f));
|
||||
Assert.That(health.GuardReadinessNormalized,
|
||||
Is.EqualTo(0.7f).Within(0.01f));
|
||||
|
||||
Assert.That(health.TryTakeDamage(0f, Vector2.left, 1.1f), Is.False);
|
||||
Assert.That(guardBlockedCount, Is.EqualTo(1));
|
||||
Assert.That(guardImpactCount, Is.EqualTo(1));
|
||||
|
||||
Assert.That(health.TryTakeDamage(25f, Vector2.left, 1.1f), Is.False);
|
||||
Assert.That(guardBlockedCount, Is.EqualTo(2));
|
||||
Assert.That(guardImpactCount, Is.EqualTo(2));
|
||||
Assert.That(health.GuardCooldownRemaining,
|
||||
Is.EqualTo(cooldownAfterFirstBlock).Within(0.05f),
|
||||
"Only the first positive-damage block in an activation recharges the guard.");
|
||||
Assert.That(
|
||||
health.GetComponent<ActiveArtifactController>().CurrentGauge,
|
||||
Is.EqualTo(artifactGaugeBefore),
|
||||
"Guard readiness is independent from the shared artifact gauge.");
|
||||
|
||||
health.GrantInvulnerability(health.GuardDuration + 0.5f);
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.IsInvulnerable, Is.True);
|
||||
Assert.That(health.TryTakeDamage(25f, Vector2.left, 1.1f), Is.False);
|
||||
Assert.That(guardBlockedCount, Is.EqualTo(2));
|
||||
Assert.That(guardImpactCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GuardRecharge_UsesConfiguredPercentOncePerActivationAndClampsToReady()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
SetPrivateField(health, "guardCooldownDuration", 0.4f);
|
||||
SetPrivateField(health, "guardSuccessCooldownRechargePercent", 25f);
|
||||
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(
|
||||
health.TryTakeDamage(25f, Vector2.right, 1.1f),
|
||||
Is.False);
|
||||
Assert.That(health.GuardCooldownRemaining,
|
||||
Is.EqualTo(0.3f).Within(0.05f));
|
||||
Assert.That(health.TryTakeDamage(25f, Vector2.left, 1.1f), Is.False);
|
||||
Assert.That(health.GuardCooldownRemaining,
|
||||
Is.EqualTo(0.3f).Within(0.05f),
|
||||
"Repeated blocks in one activation must not recharge again.");
|
||||
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.TryActivateGuard(), Is.True,
|
||||
"A new activation is available after its configured cooldown has elapsed.");
|
||||
SetPrivateField(health, "guardSuccessCooldownRechargePercent", 0f);
|
||||
Assert.That(
|
||||
health.TryTakeDamage(25f, Vector2.right, 1.1f),
|
||||
Is.False);
|
||||
Assert.That(health.GuardCooldownRemaining,
|
||||
Is.EqualTo(health.GuardCooldownDuration).Within(0.05f),
|
||||
"A zero-percent setting must leave the cooldown unchanged.");
|
||||
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
SetPrivateField(health, "guardSuccessCooldownRechargePercent", 100f);
|
||||
Assert.That(
|
||||
health.TryTakeDamage(25f, Vector2.right, 1.1f),
|
||||
Is.False);
|
||||
Assert.That(health.GuardCooldownRemaining, Is.EqualTo(0f).Within(0.01f));
|
||||
Assert.That(health.GuardReadinessNormalized, Is.EqualTo(1f).Within(0.01f));
|
||||
Assert.That(health.IsGuarding, Is.True,
|
||||
"Cooldown recharge must not end the current guard window.");
|
||||
Assert.That(health.TryActivateGuard(), Is.False,
|
||||
"A ready cooldown must not allow a second guard during the active window.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_BlocksRealMeleeAndProjectileHitsDuringActiveWindow()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
EnsureRunStarted();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerHealth health = FindHealth();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableCombatActors();
|
||||
|
||||
Rigidbody2D playerBody = health.GetComponent<Rigidbody2D>();
|
||||
Assert.That(playerBody, Is.Not.Null);
|
||||
playerBody.position = new Vector2(3f, 0f);
|
||||
Physics2D.SyncTransforms();
|
||||
|
||||
EnemyController prefab = FindCatalogPrefab(director, EnemyKind.Skeleton);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
GameObject instance = Object.Instantiate(
|
||||
prefab.gameObject,
|
||||
new Vector2(-2f, 0f),
|
||||
Quaternion.identity);
|
||||
EnemyController enemy = instance.GetComponent<EnemyController>();
|
||||
EnemyAttack attack = instance.GetComponent<EnemyAttack>();
|
||||
Assert.That(enemy, Is.Not.Null);
|
||||
Assert.That(attack, Is.Not.Null);
|
||||
yield return null;
|
||||
enemy.enabled = false;
|
||||
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
float startingHealth = health.CurrentHealth;
|
||||
attack.BeginWarning(Vector2.right, health.transform.position, 0.7f);
|
||||
attack.Activate();
|
||||
playerBody.position = new Vector2(-1.5f, 0f);
|
||||
Physics2D.SyncTransforms();
|
||||
attack.TickActive(0.05f);
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth));
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(health.GetComponent<PlayerController>().IsDamageKnockbackActive,
|
||||
Is.False);
|
||||
attack.EndAttack(cancelled: true);
|
||||
|
||||
enemy.enabled = true;
|
||||
Collider2D playerCollider = health.GetComponent<Collider2D>();
|
||||
Assert.That(playerCollider, Is.Not.Null);
|
||||
Vector2 playerHitPoint = playerCollider.bounds.center;
|
||||
EnemyProjectile projectile = EnemyProjectile.Launch(
|
||||
enemy,
|
||||
playerHitPoint - Vector2.right * 2f,
|
||||
Vector2.right,
|
||||
25f,
|
||||
500f,
|
||||
10f,
|
||||
0.06f,
|
||||
false);
|
||||
int projectileFrames = 0;
|
||||
while (projectile != null && projectileFrames++ < 10)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
projectile == null,
|
||||
Is.True,
|
||||
"The projectile should hit and resolve.");
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth));
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(health.GetComponent<PlayerController>().IsDamageKnockbackActive,
|
||||
Is.False);
|
||||
|
||||
enemy.enabled = false;
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
float healthBeforeUnguardedMelee = health.CurrentHealth;
|
||||
attack.BeginWarning(Vector2.right, health.transform.position, 0.7f);
|
||||
attack.Activate();
|
||||
attack.TickActive(0.05f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.LessThan(healthBeforeUnguardedMelee),
|
||||
"The same melee setup must deal damage after guard expires.");
|
||||
attack.EndAttack(cancelled: true);
|
||||
Object.Destroy(instance);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DamageInvulnerability_ProtectsHurtRecoveryAndExpiresAfterOneSecond()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
float startingHealth = health.CurrentHealth;
|
||||
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
float damageTime = Time.time;
|
||||
while (Time.time < damageTime + 0.51f)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(health.IsInvulnerable, Is.True);
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.False);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth - 1f));
|
||||
|
||||
while (Time.time < damageTime + health.InvulnerabilityDuration + 0.05f)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(health.IsInvulnerable, Is.False);
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth - 2f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DamageInvulnerability_AllowsMovementAndArtifactAfterHurtUnlock()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerController player = FindHealth().GetComponent<PlayerController>();
|
||||
PlayerHealth health = player.GetComponent<PlayerHealth>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
Rigidbody2D body = player.GetComponent<Rigidbody2D>();
|
||||
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
while (health.IsHurtMovementLocked)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Vector2 positionBeforeMovement = body.position;
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.RightArrow));
|
||||
yield return new WaitForFixedUpdate();
|
||||
yield return new WaitForFixedUpdate();
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
Assert.That(body.position.x, Is.GreaterThan(positionBeforeMovement.x));
|
||||
Assert.That(health.IsInvulnerable, Is.True);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_CooldownStartsOnActivationAndRejectsReactivation()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
Assert.That(health.GuardCooldownRemaining, Is.EqualTo(0f));
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(health.TryActivateGuard(), Is.False);
|
||||
Assert.That(health.GuardCooldownRemaining, Is.GreaterThan(9.9f));
|
||||
|
||||
yield return null;
|
||||
Assert.That(health.GuardCooldownRemaining, Is.LessThan(10f));
|
||||
Assert.That(health.GuardCooldownRemaining, Is.GreaterThan(9.8f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ProductionGuard_UnlocksWithRunManagerAndBecomesReadyImmediately()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
|
||||
RunManager runManager = RunManager.Instance;
|
||||
PlayerHealth health = FindHealth();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
Assert.That(tutorials, Is.Not.Null);
|
||||
Assert.That(runManager.IsTitleScreen, Is.True);
|
||||
Assert.That(runManager.BeginRun(RunMode.Production), Is.True);
|
||||
|
||||
Assert.That(runManager.IsProductionRun, Is.True);
|
||||
Assert.That(runManager.IsGuardUnlocked, Is.False);
|
||||
Assert.That(health.IsGuardAvailable, Is.False);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0f).Within(0.01f));
|
||||
Assert.That(health.TryActivateGuard(), Is.False);
|
||||
|
||||
runManager.UnlockGuard();
|
||||
|
||||
Assert.That(runManager.IsGuardUnlocked, Is.True);
|
||||
Assert.That(health.IsGuardAvailable, Is.True);
|
||||
Assert.That(health.GuardReadinessNormalized, Is.EqualTo(1f));
|
||||
Assert.That(tutorials.DebugIsVisible, Is.True);
|
||||
Assert.That(tutorials.DebugCurrentTutorial,
|
||||
Is.EqualTo(RunTutorialController.TutorialKind.Guard));
|
||||
Assert.That(health.TryActivateGuard(), Is.False,
|
||||
"The guard tutorial modal must keep gameplay input locked until continued.");
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_HeldInputDoesNotRepeatAfterCooldownAndRepressActivates()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D));
|
||||
yield return null;
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
|
||||
Time.timeScale = 10f;
|
||||
float deadline = Time.realtimeSinceStartup + 2f;
|
||||
while (health.GuardCooldownRemaining > 0.1f
|
||||
&& Time.realtimeSinceStartup < deadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(health.GuardCooldownRemaining, Is.LessThanOrEqualTo(0.1f));
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Time.timeScale = 1f;
|
||||
while (health.GuardCooldownRemaining > 0f)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D));
|
||||
yield return null;
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_OverlapsIndependentGrantInvulnerabilityWithoutHurtLock()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
health.GrantInvulnerability(health.GuardDuration + 0.5f);
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.IsInvulnerable, Is.True);
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
Assert.That(health.IsInvulnerable, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_IsUnavailableDuringHurtPauseAndAfterDeath()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
Assert.That(health.IsHurtMovementLocked, Is.True);
|
||||
Assert.That(health.TryActivateGuard(), Is.False);
|
||||
|
||||
yield return new WaitForSeconds(health.InvulnerabilityDuration + 0.05f);
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(RunManager.Instance.TryOpenPause(), Is.True);
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D));
|
||||
yield return null;
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(RunManager.Instance.ClosePause(), Is.True);
|
||||
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(
|
||||
health.TryTakeDamage(health.CurrentHealth + 1f, Vector2.zero, 0f),
|
||||
Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.Zero);
|
||||
Assert.That(health.TryActivateGuard(), Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_TimersPauseWithScaledGameTime()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(RunManager.Instance.TryOpenPause(), Is.True);
|
||||
yield return new WaitForSecondsRealtime(0.7f);
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
Assert.That(health.GuardCooldownRemaining, Is.GreaterThan(9.8f));
|
||||
Assert.That(RunManager.Instance.ClosePause(), Is.True);
|
||||
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Guard_DisablingClearsActiveGuardWithoutResettingCooldown()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
DisableCombatActors();
|
||||
EnsureRunStarted();
|
||||
PlayerHealth health = FindHealth();
|
||||
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
health.enabled = false;
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
float remainingCooldown = health.GuardCooldownRemaining;
|
||||
Assert.That(remainingCooldown, Is.GreaterThan(9.8f));
|
||||
|
||||
health.enabled = true;
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.TryActivateGuard(), Is.False);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static PlayerHealth FindHealth()
|
||||
{
|
||||
PlayerHealth health = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
Assert.That(health, Is.Not.Null);
|
||||
return health;
|
||||
}
|
||||
|
||||
private static void SetPrivateField(object target, string fieldName, object value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(
|
||||
fieldName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(field, Is.Not.Null, $"Expected private field '{fieldName}'.");
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static EnemyController FindCatalogPrefab(
|
||||
SpawnDirector director,
|
||||
EnemyKind kind)
|
||||
{
|
||||
string[] fieldNames =
|
||||
{
|
||||
"enemyPrefabs",
|
||||
"elitePrefabs",
|
||||
"midBossPrefabs",
|
||||
"finalBossPrefabs",
|
||||
};
|
||||
foreach (string fieldName in fieldNames)
|
||||
{
|
||||
FieldInfo field = typeof(SpawnDirector).GetField(
|
||||
fieldName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
EnemyController[] prefabs = field?.GetValue(director) as EnemyController[];
|
||||
if (prefabs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (EnemyController prefab in prefabs)
|
||||
{
|
||||
if (prefab != null
|
||||
&& prefab.Definition != null
|
||||
&& prefab.Definition.Kind == kind)
|
||||
{
|
||||
return prefab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void EnsureRunStarted()
|
||||
{
|
||||
RunManager runManager = RunManager.Instance;
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
if (runManager.IsTitleScreen)
|
||||
{
|
||||
Assert.That(runManager.BeginRun(), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
private Keyboard BeginVirtualKeyboardInput()
|
||||
{
|
||||
inputSettingsCaptured = true;
|
||||
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
|
||||
previousEditorInputBehavior =
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode;
|
||||
previousRunInBackground = Application.runInBackground;
|
||||
InputSystem.settings.backgroundBehavior =
|
||||
InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
Application.runInBackground = true;
|
||||
virtualKeyboard = InputSystem.AddDevice<Keyboard>();
|
||||
virtualKeyboard.MakeCurrent();
|
||||
return virtualKeyboard;
|
||||
}
|
||||
|
||||
private static void DisableCombatActors()
|
||||
{
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
director.enabled = false;
|
||||
}
|
||||
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d7b0f5e0b6f4bb7a2d2643b8f57f4a1
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.Collections;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class GuardVisualTests
|
||||
{
|
||||
[Test]
|
||||
public void GuardMasks_CoverAllSelectedColorAnimationFrames()
|
||||
{
|
||||
string[] sheets = { "Idle", "Walk", "Dash", "Hurt", "Death",
|
||||
"BumpAttack-v1", "BackBumpAttack-v2", "ArtifactUse-v1" };
|
||||
foreach (string sheet in sheets)
|
||||
{
|
||||
Sprite[] masks = Resources.LoadAll<Sprite>("Combat/Guard-v1/Swordsman_" + sheet + "-outline");
|
||||
Assert.That(masks.Length, Is.GreaterThan(0), sheet);
|
||||
foreach (string color in new[] { "Green", "Red", "Blue" })
|
||||
{
|
||||
Sprite[] frames = Resources.LoadAll<Sprite>("Artifacts/ThreeColor-v1/Player/Swordsman_" + sheet + "-" + color + "-v1");
|
||||
Assert.That(masks.Length, Is.EqualTo(frames.Length), sheet + color);
|
||||
foreach (Sprite frame in frames)
|
||||
{
|
||||
Sprite mask = System.Array.Find(masks, item => item.rect == frame.rect);
|
||||
Assert.That(mask, Is.Not.Null, frame.name);
|
||||
Assert.That(mask.pivot, Is.EqualTo(frame.pivot));
|
||||
Assert.That(mask.pixelsPerUnit, Is.EqualTo(frame.pixelsPerUnit));
|
||||
Assert.That(mask.texture.filterMode, Is.EqualTo(FilterMode.Point));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GuardOutline_FollowsFrameFlipPauseAndLifetime()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
yield return SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
yield return null;
|
||||
yield return null;
|
||||
Object.FindAnyObjectByType<SpawnDirector>().enabled = false;
|
||||
var health = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
var visual = health.GetComponent<ArtifactChargeVisual>();
|
||||
var body = health.GetComponent<SpriteRenderer>();
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
visual.RefreshEquipmentSprite();
|
||||
visual.RefreshGuardOutline();
|
||||
var outline = health.transform.Find("Player Guard Outline").GetComponent<SpriteRenderer>();
|
||||
Assert.That(outline.enabled, Is.True);
|
||||
float guardedHealth = health.CurrentHealth;
|
||||
Assert.That(health.TryTakeDamage(25f, Vector2.right, 1.1f), Is.False);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(guardedHealth));
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(outline.sprite.rect, Is.EqualTo(body.sprite.rect));
|
||||
Assert.That(outline.sprite.pivot, Is.EqualTo(body.sprite.pivot));
|
||||
Assert.That(outline.sprite.pixelsPerUnit, Is.EqualTo(body.sprite.pixelsPerUnit));
|
||||
body.flipX = true;
|
||||
body.sortingOrder = 123;
|
||||
visual.RefreshGuardOutline();
|
||||
Assert.That(outline.flipX, Is.True);
|
||||
Assert.That(outline.sortingOrder, Is.EqualTo(124));
|
||||
Time.timeScale = 0f;
|
||||
yield return new WaitForSecondsRealtime(0.6f);
|
||||
Assert.That(outline.enabled, Is.True);
|
||||
Assert.That(health.IsGuarding, Is.True);
|
||||
body.enabled = false;
|
||||
visual.RefreshGuardOutline();
|
||||
Assert.That(outline.enabled, Is.False);
|
||||
body.enabled = true;
|
||||
visual.RefreshGuardOutline();
|
||||
Assert.That(outline.enabled, Is.True);
|
||||
visual.enabled = false;
|
||||
Assert.That(outline.enabled, Is.False);
|
||||
visual.enabled = true;
|
||||
visual.RefreshGuardOutline();
|
||||
Assert.That(outline.enabled, Is.True);
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSeconds(health.GuardDuration + 0.05f);
|
||||
visual.RefreshGuardOutline();
|
||||
Assert.That(outline.enabled, Is.False);
|
||||
Assert.That(health.IsGuarding, Is.False);
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.LessThan(guardedHealth));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GuardBlockImpact_FollowsPlayerPausesAndCleansUp()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
yield return SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
yield return null;
|
||||
yield return null;
|
||||
Object.FindAnyObjectByType<SpawnDirector>().enabled = false;
|
||||
PlayerHealth health = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
CombatFeedback feedback = health.GetComponent<CombatFeedback>();
|
||||
SpriteRenderer playerRenderer = health.GetComponent<SpriteRenderer>();
|
||||
Collider2D playerCollider = health.GetComponent<Collider2D>();
|
||||
Assert.That(feedback, Is.Not.Null);
|
||||
Assert.That(playerRenderer, Is.Not.Null);
|
||||
Assert.That(playerCollider, Is.Not.Null);
|
||||
Sprite impactSprite = Resources.Load<Sprite>(
|
||||
CombatFeedback.GetGuardBlockImpactResourcePath());
|
||||
Assert.That(impactSprite, Is.Not.Null);
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
|
||||
Vector2 expectedPosition = (Vector2)playerCollider.bounds.center
|
||||
+ Vector2.left * 0.35f;
|
||||
Assert.That(
|
||||
health.TryTakeDamage(5f, Vector2.right * 3f, 0.75f),
|
||||
Is.False);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.EqualTo(1));
|
||||
Transform impact = health.transform.Find("Player Guard Block Impact");
|
||||
Assert.That(impact, Is.Not.Null);
|
||||
Assert.That(impact.parent, Is.EqualTo(health.transform));
|
||||
Assert.That(
|
||||
Vector2.Distance((Vector2)impact.position, expectedPosition),
|
||||
Is.LessThan(0.001f));
|
||||
SpriteRenderer impactRenderer = impact.GetComponent<SpriteRenderer>();
|
||||
Assert.That(impactRenderer.sprite, Is.EqualTo(impactSprite));
|
||||
Assert.That(impactRenderer.color, Is.EqualTo(Color.white));
|
||||
Assert.That(
|
||||
impactRenderer.sortingOrder,
|
||||
Is.EqualTo(playerRenderer.sortingOrder + 20));
|
||||
|
||||
Assert.That(health.TryTakeDamage(5f, Vector2.left, 0.75f), Is.False);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.EqualTo(1),
|
||||
"A second impact inside the 0.08-second interval must be suppressed.");
|
||||
|
||||
yield return new WaitForSeconds(0.09f);
|
||||
Assert.That(health.TryTakeDamage(5f, Vector2.zero, 0.75f), Is.False);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.EqualTo(2));
|
||||
Transform centeredImpact = null;
|
||||
foreach (Transform child in health.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
if (child.name == "Player Guard Block Impact"
|
||||
&& Vector2.Distance(
|
||||
(Vector2)child.position,
|
||||
(Vector2)playerCollider.bounds.center) < 0.001f)
|
||||
{
|
||||
centeredImpact = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.That(centeredImpact, Is.Not.Null,
|
||||
"A zero attack direction must place the impact at the body center.");
|
||||
|
||||
Vector3 initialImpactPosition = impact.position;
|
||||
health.transform.position += Vector3.up * 0.25f;
|
||||
Assert.That(impact.position, Is.EqualTo(initialImpactPosition + Vector3.up * 0.25f));
|
||||
|
||||
Time.timeScale = 0f;
|
||||
yield return new WaitForSecondsRealtime(0.22f);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.EqualTo(2),
|
||||
"The impact lifetime must pause with game time.");
|
||||
feedback.enabled = false;
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.Zero);
|
||||
|
||||
feedback.enabled = true;
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSeconds(0.09f);
|
||||
Assert.That(health.TryTakeDamage(5f, Vector2.right, 0.75f), Is.False);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.EqualTo(1));
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.Zero);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GuardBlockImpact_ClearsWhenPlayerDies()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
yield return SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
yield return null;
|
||||
yield return null;
|
||||
Object.FindAnyObjectByType<SpawnDirector>().enabled = false;
|
||||
PlayerHealth health = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
CombatFeedback feedback = health.GetComponent<CombatFeedback>();
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.right, 0.75f), Is.False);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.EqualTo(1));
|
||||
|
||||
health.enabled = false;
|
||||
Assert.That(
|
||||
health.TryTakeDamage(health.CurrentHealth, Vector2.right, 0f),
|
||||
Is.True);
|
||||
Assert.That(health.CurrentHealth, Is.Zero);
|
||||
Assert.That(feedback.ActiveGuardBlockImpactVisualCount, Is.Zero);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c877c1c8bb34881945b174974dc7a0e
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class HurtRecoveryVisualTests
|
||||
{
|
||||
[UnityTest]
|
||||
public IEnumerator HurtBlink_PausesWithImmunityAndClearsAfterRecovery()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
yield return SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
yield return null;
|
||||
yield return null;
|
||||
Object.FindAnyObjectByType<SpawnDirector>().enabled = false;
|
||||
foreach (var enemy in Object.FindObjectsByType<EnemyController>(FindObjectsSortMode.None))
|
||||
enemy.gameObject.SetActive(false);
|
||||
var health = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
var feedback = health.GetComponent<CombatFeedback>();
|
||||
var body = health.GetComponent<SpriteRenderer>();
|
||||
var routine = typeof(CombatFeedback).GetField("playerVisualCoroutine", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Color original = body.color;
|
||||
Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True);
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
Assert.That(RunManager.Instance.TryOpenPause(), Is.True);
|
||||
yield return null;
|
||||
Color paused = body.color;
|
||||
yield return new WaitForSecondsRealtime(1.2f);
|
||||
Assert.That(health.IsInvulnerable, Is.True);
|
||||
Assert.That(routine.GetValue(feedback), Is.Not.Null, "Damage blink must not expire on real time during pause.");
|
||||
Assert.That(body.color, Is.EqualTo(paused), "Blink phase freezes with game time.");
|
||||
Assert.That(RunManager.Instance.ClosePause(), Is.True);
|
||||
yield return new WaitForSeconds(0.4f);
|
||||
Assert.That(health.IsHurtMovementLocked, Is.False);
|
||||
Assert.That(health.IsInvulnerable, Is.True);
|
||||
Assert.That(routine.GetValue(feedback), Is.Not.Null);
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
Assert.That(health.IsInvulnerable, Is.False);
|
||||
Assert.That(routine.GetValue(feedback), Is.Null);
|
||||
Assert.That(body.color, Is.EqualTo(original));
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4dd8dda5df40472c8ba7afb1303e9794
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Collections;
|
||||
using BumpCombat.Combat;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class InteractionFeedbackPlayModeTests
|
||||
{
|
||||
private GameObject feedbackObject;
|
||||
private GameObject targetObject;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
feedbackObject = new GameObject("Feedback Test Host");
|
||||
feedbackObject.AddComponent<SpriteRenderer>();
|
||||
feedbackObject.AddComponent<CombatFeedback>();
|
||||
targetObject = new GameObject("Feedback Test Target");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (targetObject != null)
|
||||
{
|
||||
Object.Destroy(targetObject);
|
||||
}
|
||||
if (feedbackObject != null)
|
||||
{
|
||||
Object.Destroy(feedbackObject);
|
||||
}
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator BreakStatusText_CreatesTextMeshWithoutException()
|
||||
{
|
||||
CombatEvents.RaiseEnemyStagger(targetObject, 1, 1, true);
|
||||
yield return null;
|
||||
|
||||
TextMesh status = FindStatusText("BREAK");
|
||||
Assert.That(status, Is.Not.Null);
|
||||
Assert.That(status.text, Is.EqualTo("BREAK"));
|
||||
Assert.That(status.font, Is.SameAs(Resources.Load<Font>(
|
||||
"Presentation/Fonts/Galmuri9")));
|
||||
Assert.That(status.fontStyle, Is.EqualTo(FontStyle.Normal));
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ResistStatusText_CreatesTextMeshWithoutException()
|
||||
{
|
||||
CombatEvents.RaiseLaunchResisted(targetObject);
|
||||
yield return null;
|
||||
|
||||
TextMesh status = FindStatusText("RESIST");
|
||||
Assert.That(status, Is.Not.Null);
|
||||
Assert.That(status.text, Is.EqualTo("RESIST"));
|
||||
Assert.That(status.font, Is.SameAs(Resources.Load<Font>(
|
||||
"Presentation/Fonts/Galmuri9")));
|
||||
Assert.That(status.fontStyle, Is.EqualTo(FontStyle.Normal));
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator StatusTextBurst_StaysWithinHeavyLimitAndCleansUp()
|
||||
{
|
||||
CombatFeedback feedback = feedbackObject.GetComponent<CombatFeedback>();
|
||||
for (int i = 0; i < CombatFeedback.MaxConcurrentHeavyEffects + 4; i++)
|
||||
{
|
||||
CombatEvents.RaiseEnemyStagger(targetObject, 1, 1, true);
|
||||
CombatEvents.RaiseLaunchResisted(targetObject);
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
feedback.ActiveHeavyEffectCount,
|
||||
Is.LessThanOrEqualTo(CombatFeedback.MaxConcurrentHeavyEffects));
|
||||
|
||||
yield return new WaitForSecondsRealtime(0.5f);
|
||||
|
||||
Assert.That(FindStatusText("BREAK"), Is.Null);
|
||||
Assert.That(FindStatusText("RESIST"), Is.Null);
|
||||
Assert.That(
|
||||
feedback.ActiveHeavyEffectCount,
|
||||
Is.LessThanOrEqualTo(CombatFeedback.MaxConcurrentHeavyEffects));
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator BumpImpactSheets_ExpireAndRemoveEveryTransientRoot()
|
||||
{
|
||||
CombatFeedback feedback = feedbackObject.GetComponent<CombatFeedback>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
feedbackObject,
|
||||
targetObject,
|
||||
false,
|
||||
HitSide.Side,
|
||||
10f,
|
||||
Vector2.right,
|
||||
1f,
|
||||
new Vector2(i, 0f)));
|
||||
}
|
||||
|
||||
Assert.That(feedback.ActiveBumpImpactVisualCount, Is.EqualTo(8));
|
||||
yield return new WaitForSecondsRealtime(0.08f);
|
||||
|
||||
Time.timeScale = 0f;
|
||||
int pausedCount = feedback.ActiveBumpImpactVisualCount;
|
||||
yield return new WaitForSecondsRealtime(0.1f);
|
||||
Assert.That(feedback.ActiveBumpImpactVisualCount, Is.EqualTo(pausedCount));
|
||||
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSecondsRealtime(0.25f);
|
||||
Assert.That(feedback.ActiveBumpImpactVisualCount, Is.Zero);
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator OrdinaryBumpUsesGradeSheet_ArtifactHitKeepsProceduralFeedback()
|
||||
{
|
||||
CombatFeedback feedback = feedbackObject.GetComponent<CombatFeedback>();
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
feedbackObject,
|
||||
targetObject,
|
||||
false,
|
||||
HitSide.Back,
|
||||
15f,
|
||||
Vector2.up,
|
||||
1f,
|
||||
new Vector2(0.5f, 0.75f)));
|
||||
|
||||
GameObject bump = GameObject.Find("Bump Impact Strong");
|
||||
Assert.That(bump, Is.Not.Null);
|
||||
Assert.That(bump.transform.position, Is.EqualTo(new Vector3(0.5f, 0.75f, 0f)));
|
||||
Assert.That(bump.transform.eulerAngles.z, Is.EqualTo(90f).Within(0.001f));
|
||||
Assert.That(bump.GetComponent<SpriteRenderer>().color, Is.EqualTo(Color.white));
|
||||
Assert.That(GameObject.Find("Hit Impact"), Is.Null);
|
||||
Assert.That(GameObject.Find("Attack Slash"), Is.Null);
|
||||
|
||||
yield return new WaitForSecondsRealtime(0.2f);
|
||||
Time.timeScale = 1f;
|
||||
CombatEvents.RaiseValidHit(new CombatHitResult(
|
||||
feedbackObject,
|
||||
targetObject,
|
||||
false,
|
||||
HitSide.Front,
|
||||
10f,
|
||||
Vector2.right,
|
||||
1f,
|
||||
Vector2.zero,
|
||||
false,
|
||||
false,
|
||||
DamageTag.Collision,
|
||||
"Pulse"));
|
||||
|
||||
Assert.That(GameObject.Find("Hit Impact"), Is.Not.Null);
|
||||
Assert.That(GameObject.Find("Attack Slash"), Is.Not.Null);
|
||||
Assert.That(GameObject.Find("Bump Impact Weak"), Is.Null);
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
private static TextMesh FindStatusText(string status)
|
||||
{
|
||||
TextMesh[] meshes = Object.FindObjectsByType<TextMesh>(
|
||||
FindObjectsInactive.Include,
|
||||
FindObjectsSortMode.None);
|
||||
foreach (TextMesh mesh in meshes)
|
||||
{
|
||||
if (mesh != null && mesh.gameObject.name == $"Status {status}")
|
||||
{
|
||||
return mesh;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d4ea804c0f8d4e74962e6b7d5a4f1c33
|
||||
@@ -0,0 +1,325 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class LancerThrustPlayModeTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LancerThrust_VisualUsesLockedHandAndCleansUpAcrossDirections()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
EnemyController lancer = SpawnLancer();
|
||||
EnemyAttack attack = lancer.GetComponent<EnemyAttack>();
|
||||
LancerThrustVisual visual = lancer.GetComponent<LancerThrustVisual>();
|
||||
SpriteRenderer source = lancer.GetComponent<SpriteRenderer>();
|
||||
lancer.enabled = false;
|
||||
|
||||
Vector2[] directions =
|
||||
{
|
||||
Vector2.right,
|
||||
Vector2.left,
|
||||
Vector2.up,
|
||||
new Vector2(-1f, 1f).normalized,
|
||||
};
|
||||
foreach (Vector2 direction in directions)
|
||||
{
|
||||
attack.BeginWarning(direction, (Vector2)lancer.transform.position + direction);
|
||||
SetState(lancer, EnemyState.Warning, lancer.WarningDuration);
|
||||
yield return null;
|
||||
|
||||
Assert.That(visual, Is.Not.Null);
|
||||
Assert.That(visual.IsVisible, Is.True);
|
||||
Assert.That(source.enabled, Is.False);
|
||||
Vector2 expectedHand = (Vector2)lancer.transform.position
|
||||
+ LancerAttackMotion.GetHandAnchorOffset(
|
||||
direction,
|
||||
Mathf.Abs(lancer.transform.lossyScale.x));
|
||||
Assert.That(
|
||||
Vector2.Distance(visual.SpearRenderer.transform.position, expectedHand),
|
||||
Is.LessThan(0.0001f));
|
||||
Assert.That(
|
||||
Mathf.Abs(Mathf.DeltaAngle(
|
||||
visual.SpearRenderer.transform.eulerAngles.z,
|
||||
Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg)),
|
||||
Is.LessThan(0.001f));
|
||||
|
||||
attack.EndAttack(true);
|
||||
yield return null;
|
||||
Assert.That(visual.IsVisible, Is.False);
|
||||
Assert.That(source.enabled, Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LancerThrust_SweepsTipAndRetriesBlockedHitOnce()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
PlayerHealth health = UnityEngine.Object.FindAnyObjectByType<PlayerHealth>();
|
||||
Rigidbody2D playerBody = health.GetComponent<Rigidbody2D>();
|
||||
Collider2D playerCollider = health.GetComponent<Collider2D>();
|
||||
EnemyController lancer = SpawnLancer();
|
||||
EnemyAttack attack = lancer.GetComponent<EnemyAttack>();
|
||||
lancer.enabled = false;
|
||||
lancer.transform.position = Vector2.zero;
|
||||
lancer.GetComponent<Rigidbody2D>().position = Vector2.zero;
|
||||
Vector2 direction = Vector2.right;
|
||||
float scale = Mathf.Abs(lancer.transform.lossyScale.x);
|
||||
Vector2 futureTip = LancerAttackMotion.GetTipPosition(
|
||||
lancer.transform.position,
|
||||
direction,
|
||||
lancer.AttackLength,
|
||||
1f,
|
||||
scale);
|
||||
PlacePlayerAtColliderCenter(playerBody, playerCollider, futureTip);
|
||||
Physics2D.SyncTransforms();
|
||||
|
||||
attack.BeginWarning(direction, futureTip);
|
||||
SetState(lancer, EnemyState.Warning, lancer.WarningDuration);
|
||||
float healthBefore = health.CurrentHealth;
|
||||
attack.Activate();
|
||||
SetState(lancer, EnemyState.Active, lancer.ActiveDuration);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
attack.TickActive(lancer.ActiveDuration * 0.1f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
health.GrantInvulnerability(0.25f);
|
||||
attack.TickActive(lancer.ActiveDuration
|
||||
* LancerAttackMotion.ActiveExtensionEndFraction);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
yield return new WaitForSecondsRealtime(0.3f);
|
||||
PlacePlayerAtColliderCenter(playerBody, playerCollider, futureTip);
|
||||
Physics2D.SyncTransforms();
|
||||
Assert.That(health.IsInvulnerable, Is.False);
|
||||
attack.TickActive(lancer.ActiveDuration
|
||||
* LancerAttackMotion.ActiveExtensionEndFraction);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f));
|
||||
|
||||
float immunityDeadline = Time.realtimeSinceStartup
|
||||
+ health.InvulnerabilityDuration + 1f;
|
||||
while (health.IsInvulnerable
|
||||
&& Time.realtimeSinceStartup < immunityDeadline)
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(0.05f);
|
||||
}
|
||||
Assert.That(health.IsInvulnerable, Is.False,
|
||||
"The same thrust is retried only after the prior hit's real invulnerability ends.");
|
||||
PlacePlayerAtColliderCenter(playerBody, playerCollider, futureTip);
|
||||
Physics2D.SyncTransforms();
|
||||
attack.TickActive(lancer.ActiveDuration
|
||||
* LancerAttackMotion.ActiveExtensionEndFraction);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f));
|
||||
|
||||
// A completed extension must not retry the stale tip during return.
|
||||
attack.EndAttack(false);
|
||||
attack.BeginWarning(direction, futureTip);
|
||||
SetState(lancer, EnemyState.Warning, lancer.WarningDuration);
|
||||
attack.Activate();
|
||||
SetState(lancer, EnemyState.Active, lancer.ActiveDuration);
|
||||
health.GrantInvulnerability(0.15f);
|
||||
float afterFirstThrust = health.CurrentHealth;
|
||||
attack.TickActive(lancer.ActiveDuration
|
||||
* LancerAttackMotion.ActiveExtensionEndFraction);
|
||||
yield return new WaitForSecondsRealtime(0.2f);
|
||||
Assert.That(health.IsInvulnerable, Is.False);
|
||||
attack.TickActive(lancer.ActiveDuration * 0.8f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(afterFirstThrust));
|
||||
|
||||
attack.EndAttack(false);
|
||||
SetState(lancer, EnemyState.Recovery, lancer.RecoveryDuration);
|
||||
attack.TickActive(lancer.ActiveDuration);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LancerThrust_KnockbackRebaseDoesNotSweepDisplacement()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
PlayerHealth health = UnityEngine.Object.FindAnyObjectByType<PlayerHealth>();
|
||||
Rigidbody2D playerBody = health.GetComponent<Rigidbody2D>();
|
||||
Collider2D playerCollider = health.GetComponent<Collider2D>();
|
||||
EnemyController lancer = SpawnLancer();
|
||||
EnemyAttack attack = lancer.GetComponent<EnemyAttack>();
|
||||
lancer.enabled = false;
|
||||
|
||||
Rigidbody2D lancerBody = lancer.GetComponent<Rigidbody2D>();
|
||||
Vector2 direction = Vector2.right;
|
||||
Vector2 oldRoot = Vector2.zero;
|
||||
lancer.transform.position = oldRoot;
|
||||
lancerBody.position = oldRoot;
|
||||
Vector2 farFromPath = new Vector2(5f, 5f);
|
||||
PlacePlayerAtColliderCenter(playerBody, playerCollider, farFromPath);
|
||||
Physics2D.SyncTransforms();
|
||||
|
||||
attack.BeginWarning(direction, farFromPath);
|
||||
SetState(lancer, EnemyState.Warning, lancer.WarningDuration);
|
||||
attack.Activate();
|
||||
SetState(lancer, EnemyState.Active, lancer.ActiveDuration);
|
||||
attack.TickActive(lancer.ActiveDuration * 0.1f);
|
||||
|
||||
float scale = Mathf.Abs(lancer.transform.lossyScale.x);
|
||||
float firstProgress = 0.1f;
|
||||
Vector2 oldTip = LancerAttackMotion.GetTipPosition(
|
||||
oldRoot,
|
||||
direction,
|
||||
lancer.AttackLength,
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(firstProgress),
|
||||
scale);
|
||||
Vector2 newRoot = new Vector2(0f, 3f);
|
||||
lancer.transform.position = newRoot;
|
||||
lancerBody.position = newRoot;
|
||||
Physics2D.SyncTransforms();
|
||||
Vector2 newTipAtFirstProgress = LancerAttackMotion.GetTipPosition(
|
||||
newRoot,
|
||||
direction,
|
||||
lancer.AttackLength,
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(firstProgress),
|
||||
scale);
|
||||
attack.RebaseActiveTipAfterExternalMotion();
|
||||
|
||||
float healthBefore = health.CurrentHealth;
|
||||
PlacePlayerAtColliderCenter(
|
||||
playerBody,
|
||||
playerCollider,
|
||||
Vector2.Lerp(oldTip, newTipAtFirstProgress, 0.5f));
|
||||
Physics2D.SyncTransforms();
|
||||
attack.TickActive(lancer.ActiveDuration * 0.2f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
Vector2 resumedTip = LancerAttackMotion.GetTipPosition(
|
||||
newRoot,
|
||||
direction,
|
||||
lancer.AttackLength,
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(0.5f),
|
||||
scale);
|
||||
PlacePlayerAtColliderCenter(playerBody, playerCollider, resumedTip);
|
||||
Physics2D.SyncTransforms();
|
||||
attack.TickActive(lancer.ActiveDuration * 0.5f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthBefore - lancer.AttackDamage).Within(0.0001f));
|
||||
}
|
||||
|
||||
private static EnemyController SpawnLancer()
|
||||
{
|
||||
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
director.DebugSpawnImmediate(3);
|
||||
|
||||
// Lancer remains a legacy motion regression fixture even though
|
||||
// the stage catalog now contains only the requested ambient and
|
||||
// event roster. Load the preserved prefab directly in the Editor
|
||||
// test process instead of coupling this helper to wave contents.
|
||||
GameObject lancerPrefab = LoadLegacyPrefab(
|
||||
"Assets/_Project/Prefabs/Enemies/Lancer.prefab");
|
||||
Assert.That(lancerPrefab, Is.Not.Null);
|
||||
EnemyController spawnedLancer = UnityEngine.Object.Instantiate(
|
||||
lancerPrefab,
|
||||
Vector3.zero,
|
||||
Quaternion.identity)
|
||||
.GetComponent<EnemyController>();
|
||||
Assert.That(spawnedLancer, Is.Not.Null);
|
||||
|
||||
EnemyController[] enemies = UnityEngine.Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.InstanceID);
|
||||
foreach (EnemyController enemy in enemies)
|
||||
{
|
||||
if (enemy != spawnedLancer)
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
return spawnedLancer;
|
||||
}
|
||||
|
||||
private static GameObject LoadLegacyPrefab(string assetPath)
|
||||
{
|
||||
Type assetDatabaseType = Type.GetType("UnityEditor.AssetDatabase, UnityEditor");
|
||||
Assert.That(assetDatabaseType, Is.Not.Null, "UnityEditor.AssetDatabase is unavailable.");
|
||||
MethodInfo loadAssetAtPath = null;
|
||||
foreach (MethodInfo method in assetDatabaseType.GetMethods(
|
||||
BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (method.Name == "LoadAssetAtPath"
|
||||
&& method.IsGenericMethodDefinition
|
||||
&& method.GetGenericArguments().Length == 1)
|
||||
{
|
||||
loadAssetAtPath = method;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.That(loadAssetAtPath, Is.Not.Null);
|
||||
MethodInfo loadPrefab = loadAssetAtPath.MakeGenericMethod(typeof(GameObject));
|
||||
return loadPrefab.Invoke(null, new object[] { assetPath }) as GameObject;
|
||||
}
|
||||
|
||||
private static void PlacePlayerAtColliderCenter(
|
||||
Rigidbody2D body,
|
||||
Collider2D collider,
|
||||
Vector2 desiredCenter)
|
||||
{
|
||||
Vector2 offset = (Vector2)collider.bounds.center - body.position;
|
||||
body.position = desiredCenter - offset;
|
||||
}
|
||||
|
||||
private static void SetState(
|
||||
EnemyController controller,
|
||||
EnemyState state,
|
||||
float duration)
|
||||
{
|
||||
typeof(EnemyController)
|
||||
.GetMethod("EnterState", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.Invoke(controller, new object[] { state, duration });
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
Assert.That(Time.timeScale, Is.EqualTo(1f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8db8e14c60d041959e6b8a1314ad0351
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b1e561fd452b40a998e04eeac6d1e220
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,665 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class NecromancerSummonPlayModeRegressionTests
|
||||
{
|
||||
private const float ExpectedSummonSpawnRadius = 2.5f;
|
||||
private const float ExpectedSummonSpawnMinimumSeparation = 1.5f;
|
||||
private const float ExpectedSummonPlayerClearance = 1.25f;
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
private bool previousGrantCatalogForTests;
|
||||
private bool previousProductionModeForTests;
|
||||
private float previousTimeScale;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
|
||||
previousProductionModeForTests = RunManager.ForceProductionModeForTests;
|
||||
previousTimeScale = Time.timeScale;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests;
|
||||
RunManager.ForceProductionModeForTests = previousProductionModeForTests;
|
||||
Time.timeScale = previousTimeScale;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AmbientSummonsWaitForWarningActiveAndRecoveryToFinish()
|
||||
{
|
||||
yield return LoadCombatScene();
|
||||
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
player.GetComponent<PlayerHealth>().GrantInvulnerability(60f);
|
||||
|
||||
EnemyController boss = UnityEngine.Object.Instantiate(
|
||||
FindCatalogPrefab(director, EnemyKind.Necromancer),
|
||||
player.transform.position + Vector3.right * 7f,
|
||||
Quaternion.identity);
|
||||
yield return WaitForReady(boss);
|
||||
NecromancerBossController necromancer =
|
||||
boss.GetComponent<NecromancerBossController>();
|
||||
Assert.That(necromancer, Is.Not.Null);
|
||||
|
||||
float generalDueTime = Time.time - 0.1f;
|
||||
float crowdDueTime = Time.time - 0.1f;
|
||||
SetPrivateField(necromancer, "nextGeneralSummonTime", generalDueTime);
|
||||
SetPrivateField(necromancer, "nextCrowdSummonTime", crowdDueTime);
|
||||
MethodInfo enterState = typeof(EnemyController).GetMethod(
|
||||
"EnterState",
|
||||
NonPublicInstance);
|
||||
MethodInfo update = typeof(NecromancerBossController).GetMethod(
|
||||
"Update",
|
||||
NonPublicInstance);
|
||||
Assert.That(enterState, Is.Not.Null);
|
||||
Assert.That(update, Is.Not.Null);
|
||||
|
||||
foreach (EnemyState attackState in new[]
|
||||
{
|
||||
EnemyState.Warning,
|
||||
EnemyState.Active,
|
||||
EnemyState.Recovery,
|
||||
})
|
||||
{
|
||||
enterState.Invoke(boss, new object[] { attackState, 5f });
|
||||
Assert.That(boss.IsAttackSequenceInProgress, Is.True);
|
||||
|
||||
update.Invoke(necromancer, null);
|
||||
|
||||
Assert.That(boss.State, Is.EqualTo(attackState));
|
||||
Assert.That(GetPrivateFloat(necromancer, "nextGeneralSummonTime"),
|
||||
Is.EqualTo(generalDueTime));
|
||||
Assert.That(GetPrivateFloat(necromancer, "nextCrowdSummonTime"),
|
||||
Is.EqualTo(crowdDueTime));
|
||||
Assert.That(CountOwnedSummons(boss), Is.Zero);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
enterState.Invoke(boss, new object[] { EnemyState.Chase, 0f });
|
||||
update.Invoke(necromancer, null);
|
||||
Assert.That(CountOwnedSummons(boss), Is.EqualTo(1),
|
||||
"A due general summon should start once the attack sequence is safe.");
|
||||
float firstSummonStartedAt = Time.time;
|
||||
Assert.That(GetPrivateFloat(necromancer, "nextGeneralSummonTime"),
|
||||
Is.GreaterThan(Time.time));
|
||||
Assert.That(GetPrivateFloat(necromancer, "nextCrowdSummonTime"),
|
||||
Is.LessThanOrEqualTo(Time.time),
|
||||
"The other due timer must remain pending after a successful summon.");
|
||||
Assert.That(boss.GetSummonAnimationDuration(),
|
||||
Is.EqualTo(1.25f).Within(0.005f));
|
||||
Assert.That(GetPrivateFloat(necromancer, "summonLockUntil") - firstSummonStartedAt,
|
||||
Is.EqualTo(1.25f).Within(0.03f),
|
||||
"The caster's movement lock must cover the full Summon clip.");
|
||||
|
||||
update.Invoke(necromancer, null);
|
||||
Assert.That(CountOwnedSummons(boss), Is.EqualTo(1),
|
||||
"A single Update must not start both due ambient summon types.");
|
||||
|
||||
yield return new WaitForSeconds(1f);
|
||||
Assert.That(CountOwnedSummons(boss), Is.EqualTo(1),
|
||||
"The due crowd summon must stay pending during the 1.25-second caster clip.");
|
||||
Assert.That(boss.GetComponent<Animator>()
|
||||
.GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
||||
Is.True,
|
||||
"The caster must still show its Summon animation at one second.");
|
||||
|
||||
float secondSummonDeadline = Time.realtimeSinceStartup + 3f;
|
||||
while (Time.realtimeSinceStartup < secondSummonDeadline
|
||||
&& CountOwnedSummons(boss) < 4)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(CountOwnedSummons(boss), Is.EqualTo(4),
|
||||
"The deferred crowd summon should start after the first summon lock ends.");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SummonedRiseClipsReachTheirLastSpriteBeforeChase()
|
||||
{
|
||||
yield return LoadCombatScene();
|
||||
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
player.GetComponent<PlayerHealth>().GrantInvulnerability(60f);
|
||||
|
||||
EnemyKind[] kinds =
|
||||
{
|
||||
EnemyKind.Bat,
|
||||
EnemyKind.Slime,
|
||||
EnemyKind.Skeleton,
|
||||
EnemyKind.GreatswordSkeleton,
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
EnemyKind.SkeletonArcher,
|
||||
EnemyKind.Necrofire,
|
||||
EnemyKind.Werewolf,
|
||||
EnemyKind.Werebear,
|
||||
EnemyKind.NecroGolem,
|
||||
};
|
||||
EnemyController[] summoned = new EnemyController[kinds.Length];
|
||||
Animator[] animators = new Animator[kinds.Length];
|
||||
SpriteRenderer[] renderers = new SpriteRenderer[kinds.Length];
|
||||
AnimationClip[] summonClips = new AnimationClip[kinds.Length];
|
||||
float[] expectedDurations = new float[kinds.Length];
|
||||
Sprite[] expectedFirstSprites = new Sprite[kinds.Length];
|
||||
Sprite[] expectedFinalSprites = new Sprite[kinds.Length];
|
||||
bool[] sawFinalSpriteDuringSpawn = new bool[kinds.Length];
|
||||
|
||||
for (int i = 0; i < kinds.Length; i++)
|
||||
{
|
||||
EnemyController prefab = FindCatalogPrefab(director, kinds[i]);
|
||||
Animator prefabAnimator = prefab.GetComponent<Animator>();
|
||||
AnimationClip summonClip = FindSummonClip(prefabAnimator);
|
||||
Assert.That(summonClip, Is.Not.Null,
|
||||
$"{kinds[i]} must have a dedicated Summon clip.");
|
||||
expectedDurations[i] = ExpectedSummonDuration(kinds[i]);
|
||||
Assert.That(summonClip.length,
|
||||
Is.EqualTo(expectedDurations[i]).Within(0.005f));
|
||||
summonClips[i] = summonClip;
|
||||
|
||||
expectedFirstSprites[i] = SampleSprite(summonClip, 0f);
|
||||
expectedFinalSprites[i] = SampleSprite(
|
||||
summonClip,
|
||||
summonClip.length - 0.001f);
|
||||
|
||||
summoned[i] = UnityEngine.Object.Instantiate(
|
||||
prefab,
|
||||
player.transform.position + new Vector3(1.5f + i, 2f, 0f),
|
||||
Quaternion.identity);
|
||||
summoned[i].ConfigureSummonedEnemy(
|
||||
null,
|
||||
CreateNeutralTuning(prefab),
|
||||
i % 2 == 0);
|
||||
animators[i] = summoned[i].GetComponent<Animator>();
|
||||
renderers[i] = summoned[i].GetComponent<SpriteRenderer>();
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
for (int i = 0; i < summoned.Length; i++)
|
||||
{
|
||||
Assert.That(summoned[i].MaximumHealth, Is.GreaterThan(0f));
|
||||
Assert.That(summoned[i].State, Is.EqualTo(EnemyState.Spawn));
|
||||
Assert.That(summoned[i].IsPhaseSummon, Is.EqualTo(i % 2 == 0));
|
||||
Assert.That(summoned[i].StateDuration,
|
||||
Is.EqualTo(expectedDurations[i]).Within(0.005f));
|
||||
Assert.That(summoned[i].GetSummonAnimationDuration(),
|
||||
Is.EqualTo(expectedDurations[i]).Within(0.005f));
|
||||
Assert.That(animators[i].GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
||||
Is.True,
|
||||
DescribeAnimator(kinds[i], animators[i], renderers[i],
|
||||
"did not enter its rise animation"));
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(0.3f);
|
||||
for (int i = 0; i < summoned.Length; i++)
|
||||
{
|
||||
Assert.That(summoned[i].State, Is.EqualTo(EnemyState.Spawn));
|
||||
Assert.That(animators[i].GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
||||
Is.True,
|
||||
DescribeAnimator(kinds[i], animators[i], renderers[i],
|
||||
"left the rise animation before its clip completed"));
|
||||
AnimatorStateInfo state = animators[i].GetCurrentAnimatorStateInfo(0);
|
||||
float elapsed = Mathf.Min(
|
||||
state.normalizedTime * summonClips[i].length,
|
||||
summonClips[i].length - 0.001f);
|
||||
Sprite expectedCurrentSprite = SampleSprite(summonClips[i], elapsed);
|
||||
Assert.That(renderers[i].sprite, Is.SameAs(expectedCurrentSprite),
|
||||
$"{kinds[i]} sprite did not match the sampled rise frame.");
|
||||
Assert.That(renderers[i].sprite, Is.Not.SameAs(expectedFirstSprites[i]),
|
||||
$"{kinds[i]} did not advance beyond the first rise frame.");
|
||||
Assert.That(renderers[i].sprite, Is.Not.SameAs(expectedFinalSprites[i]),
|
||||
$"{kinds[i]} must keep rising until the clip completes.");
|
||||
}
|
||||
|
||||
float finalFrameDeadline = Time.realtimeSinceStartup + 1.5f;
|
||||
while (Time.realtimeSinceStartup < finalFrameDeadline
|
||||
&& !AllTrue(sawFinalSpriteDuringSpawn))
|
||||
{
|
||||
for (int i = 0; i < summoned.Length; i++)
|
||||
{
|
||||
if (summoned[i].State == EnemyState.Spawn
|
||||
&& renderers[i].sprite == expectedFinalSprites[i])
|
||||
{
|
||||
sawFinalSpriteDuringSpawn[i] = true;
|
||||
}
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
for (int i = 0; i < summoned.Length; i++)
|
||||
{
|
||||
Assert.That(sawFinalSpriteDuringSpawn[i], Is.True,
|
||||
$"{kinds[i]} must display its final rise sprite before Chase.");
|
||||
}
|
||||
|
||||
float chaseDeadline = Time.realtimeSinceStartup + 1f;
|
||||
while (Time.realtimeSinceStartup < chaseDeadline
|
||||
&& Array.Exists(summoned, enemy => enemy.State == EnemyState.Spawn))
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
for (int i = 0; i < summoned.Length; i++)
|
||||
{
|
||||
Assert.That(summoned[i].State, Is.Not.EqualTo(EnemyState.Spawn),
|
||||
$"{kinds[i]} should resume normal AI only after the rise clip.");
|
||||
UnityEngine.Object.Destroy(summoned[i].gameObject);
|
||||
}
|
||||
|
||||
EnemyController necrofirePrefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.Necrofire);
|
||||
EnemyController necrofireSummon = UnityEngine.Object.Instantiate(
|
||||
necrofirePrefab,
|
||||
player.transform.position + Vector3.left * 7f,
|
||||
Quaternion.identity);
|
||||
necrofireSummon.ConfigureSummonedEnemy(
|
||||
null,
|
||||
CreateNeutralTuning(necrofirePrefab),
|
||||
false);
|
||||
yield return null;
|
||||
yield return null;
|
||||
Assert.That(necrofireSummon.GetSummonAnimationDuration(),
|
||||
Is.EqualTo(0.75f).Within(0.005f));
|
||||
Assert.That(necrofireSummon.State, Is.EqualTo(EnemyState.Spawn));
|
||||
Assert.That(necrofireSummon.StateDuration, Is.EqualTo(0.75f).Within(0.005f));
|
||||
Assert.That(necrofireSummon.GetComponent<Animator>()
|
||||
.GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
||||
Is.True,
|
||||
"Necrofire summons must use their authored rise animation.");
|
||||
|
||||
float summonDeadline = Time.realtimeSinceStartup + 1f;
|
||||
while (Time.realtimeSinceStartup < summonDeadline
|
||||
&& necrofireSummon.State == EnemyState.Spawn)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(necrofireSummon.State, Is.Not.EqualTo(EnemyState.Spawn));
|
||||
|
||||
EnemyController waveEnemy = UnityEngine.Object.Instantiate(
|
||||
necrofirePrefab,
|
||||
player.transform.position + Vector3.left * 8f,
|
||||
Quaternion.identity);
|
||||
yield return null;
|
||||
yield return null;
|
||||
Assert.That(waveEnemy.GetSummonAnimationDuration(),
|
||||
Is.EqualTo(0.75f).Within(0.005f),
|
||||
"The prefab may provide a Summon clip without playing it for a normal wave spawn.");
|
||||
Assert.That(waveEnemy.State, Is.EqualTo(EnemyState.Spawn));
|
||||
Assert.That(waveEnemy.StateDuration, Is.EqualTo(0.15f).Within(0.005f),
|
||||
"Non-summoned wave enemies keep the existing short Spawn timing.");
|
||||
Assert.That(waveEnemy.GetComponent<Animator>()
|
||||
.GetCurrentAnimatorStateInfo(0).IsName("Summon"),
|
||||
Is.False,
|
||||
"Normal wave spawns must not play the necromancer rise animation.");
|
||||
UnityEngine.Object.Destroy(necrofireSummon.gameObject);
|
||||
UnityEngine.Object.Destroy(waveEnemy.gameObject);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AmbientAndProtectedSummonsRiseNearOwnerAtArenaEdges()
|
||||
{
|
||||
yield return LoadCombatScene();
|
||||
SpawnDirector director = UnityEngine.Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = UnityEngine.Object.FindAnyObjectByType<PlayerController>();
|
||||
ArenaBounds bounds = ArenaBounds.Resolve();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
player.GetComponent<PlayerHealth>().GrantInvulnerability(60f);
|
||||
int initialAliveCount = EnemyController.AliveCount;
|
||||
|
||||
EnemyController necromancerPrefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.Necromancer);
|
||||
Vector2 halfExtents = director.SpawnReachableHalfExtents;
|
||||
EnemyController edgeOwner = UnityEngine.Object.Instantiate(
|
||||
necromancerPrefab,
|
||||
Vector2.zero,
|
||||
Quaternion.identity);
|
||||
yield return WaitForReady(edgeOwner);
|
||||
Vector2 edgePosition = new(halfExtents.x, 0f);
|
||||
Assert.That(edgeOwner.TryReposition(edgePosition), Is.True);
|
||||
|
||||
Assert.That(director.TrySpawnAmbientSummons(
|
||||
edgeOwner,
|
||||
3,
|
||||
true,
|
||||
out List<EnemyController> ambientSummons), Is.True);
|
||||
AssertSummonsAreNearOwner(
|
||||
edgeOwner,
|
||||
ambientSummons,
|
||||
bounds,
|
||||
player,
|
||||
ExpectedSummonSpawnMinimumSeparation);
|
||||
|
||||
EnemyController cornerOwner = UnityEngine.Object.Instantiate(
|
||||
necromancerPrefab,
|
||||
Vector2.zero,
|
||||
Quaternion.identity);
|
||||
yield return WaitForReady(cornerOwner);
|
||||
Vector2 cornerPosition = new(-halfExtents.x, halfExtents.y);
|
||||
Assert.That(cornerOwner.TryReposition(cornerPosition), Is.True);
|
||||
|
||||
SetPrivateField(
|
||||
director,
|
||||
"maximumAliveEnemies",
|
||||
EnemyController.AliveCount + 3);
|
||||
Assert.That(director.TrySpawnPhaseSummons(
|
||||
cornerOwner,
|
||||
2,
|
||||
true,
|
||||
out List<EnemyController> phaseSummons), Is.True);
|
||||
AssertSummonsAreNearOwner(
|
||||
cornerOwner,
|
||||
phaseSummons,
|
||||
bounds,
|
||||
player,
|
||||
0.25f);
|
||||
for (int i = 0; i < phaseSummons.Count; i++)
|
||||
{
|
||||
for (int j = 0; j < ambientSummons.Count; j++)
|
||||
{
|
||||
Assert.That(Vector2.Distance(
|
||||
phaseSummons[i].transform.position,
|
||||
ambientSummons[j].transform.position),
|
||||
Is.GreaterThanOrEqualTo(0.25f - 0.001f),
|
||||
"A later summon must avoid the same position as an existing summon.");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.That(EnemyController.AliveCount, Is.EqualTo(initialAliveCount + 8),
|
||||
"The two casters and six summons must fit under the boss encounter cap.");
|
||||
SetPrivateField(director, "maximumAliveEnemies", 100);
|
||||
Assert.That(director.TrySpawnAmbientSummons(
|
||||
cornerOwner,
|
||||
1,
|
||||
true,
|
||||
out List<EnemyController> rejectedAmbientSummons), Is.False,
|
||||
"Ambient summons must stop at the boss encounter cap.");
|
||||
Assert.That(rejectedAmbientSummons, Is.Empty);
|
||||
SetPrivateField(director, "maximumAliveEnemies", EnemyController.AliveCount);
|
||||
Assert.That(director.TrySpawnPhaseSummons(
|
||||
cornerOwner,
|
||||
2,
|
||||
true,
|
||||
out List<EnemyController> rejectedSummons), Is.False,
|
||||
"Protected summons must remain blocked at the global hard cap.");
|
||||
Assert.That(rejectedSummons, Is.Empty);
|
||||
Assert.That(ambientSummons.TrueForAll(enemy => enemy != null && !enemy.IsDead), Is.True);
|
||||
Assert.That(phaseSummons.TrueForAll(enemy => enemy != null && !enemy.IsDead), Is.True);
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatScene()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static IEnumerator WaitForReady(EnemyController enemy)
|
||||
{
|
||||
float deadline = Time.realtimeSinceStartup + 3f;
|
||||
while (Time.realtimeSinceStartup < deadline
|
||||
&& enemy != null
|
||||
&& (enemy.MaximumHealth <= 0f || enemy.State == EnemyState.Spawn))
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(enemy, Is.Not.Null);
|
||||
Assert.That(enemy.MaximumHealth, Is.GreaterThan(0f));
|
||||
Assert.That(enemy.State, Is.Not.EqualTo(EnemyState.Spawn));
|
||||
}
|
||||
|
||||
private static EnemyController FindCatalogPrefab(
|
||||
SpawnDirector director,
|
||||
EnemyKind kind)
|
||||
{
|
||||
string[] fieldNames =
|
||||
{
|
||||
"enemyPrefabs",
|
||||
"elitePrefabs",
|
||||
"midBossPrefabs",
|
||||
"finalBossPrefabs",
|
||||
};
|
||||
foreach (string fieldName in fieldNames)
|
||||
{
|
||||
FieldInfo field = typeof(SpawnDirector).GetField(
|
||||
fieldName,
|
||||
NonPublicInstance);
|
||||
EnemyController[] prefabs = field?.GetValue(director) as EnemyController[];
|
||||
if (prefabs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (EnemyController prefab in prefabs)
|
||||
{
|
||||
if (prefab != null
|
||||
&& prefab.Definition != null
|
||||
&& prefab.Definition.Kind == kind)
|
||||
{
|
||||
return prefab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Fail($"Could not find the {kind} catalog prefab.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AnimationClip FindSummonClip(Animator animator)
|
||||
{
|
||||
if (animator == null || animator.runtimeAnimatorController == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AnimationClip[] clips = animator.runtimeAnimatorController.animationClips;
|
||||
for (int i = 0; i < clips.Length; i++)
|
||||
{
|
||||
if (clips[i] != null
|
||||
&& clips[i].name.IndexOf("Summon", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return clips[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Sprite SampleSprite(AnimationClip clip, float time)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// Read the authored expectation independently of runtime Animator evaluation.
|
||||
// Sampling an unbound preview object during PlayMode can leave its sprite null.
|
||||
foreach (var binding in UnityEditor.AnimationUtility.GetObjectReferenceCurveBindings(clip))
|
||||
{
|
||||
if (binding.type != typeof(SpriteRenderer) || binding.propertyName != "m_Sprite")
|
||||
continue;
|
||||
Sprite expected = null;
|
||||
foreach (var key in UnityEditor.AnimationUtility.GetObjectReferenceCurve(clip, binding))
|
||||
if (key.time <= time) expected = key.value as Sprite;
|
||||
Assert.That(expected, Is.Not.Null, "The authored summon frame must resolve.");
|
||||
return expected;
|
||||
}
|
||||
Assert.Fail("The summon clip has no SpriteRenderer curve.");
|
||||
#else
|
||||
Assert.Ignore("Authored sprite-curve verification requires the Unity Editor.");
|
||||
#endif
|
||||
return null;
|
||||
}
|
||||
|
||||
private static float ExpectedSummonDuration(EnemyKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case EnemyKind.Bat:
|
||||
case EnemyKind.Slime:
|
||||
case EnemyKind.Werewolf:
|
||||
case EnemyKind.Werebear:
|
||||
return 0.5f;
|
||||
case EnemyKind.Necrofire:
|
||||
case EnemyKind.NecroGolem:
|
||||
return 0.75f;
|
||||
default:
|
||||
return 0.625f;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertSummonsAreNearOwner(
|
||||
EnemyController owner,
|
||||
IReadOnlyList<EnemyController> summons,
|
||||
ArenaBounds bounds,
|
||||
PlayerController player,
|
||||
float minimumGroupSpacing)
|
||||
{
|
||||
Assert.That(summons.Count, Is.EqualTo(3));
|
||||
for (int i = 0; i < summons.Count; i++)
|
||||
{
|
||||
EnemyController summon = summons[i];
|
||||
Vector2 position = summon.transform.position;
|
||||
Assert.That(summon.IsSummoned, Is.True);
|
||||
Assert.That(summon.SummonOwner, Is.SameAs(owner));
|
||||
Assert.That(Vector2.Distance(owner.transform.position, position),
|
||||
Is.LessThanOrEqualTo(ExpectedSummonSpawnRadius + 0.001f),
|
||||
"A Necromancer summon should appear on the nearby ring around its owner.");
|
||||
Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True,
|
||||
"A nearby summon must remain on the reachable arena floor.");
|
||||
Assert.That(Vector2.Distance(position, player.transform.position),
|
||||
Is.GreaterThanOrEqualTo(ExpectedSummonPlayerClearance - 0.001f),
|
||||
"A nearby summon must not overlap the player.");
|
||||
|
||||
for (int j = 0; j < i; j++)
|
||||
{
|
||||
Assert.That(Vector2.Distance(position, summons[j].transform.position),
|
||||
Is.GreaterThanOrEqualTo(minimumGroupSpacing - 0.001f),
|
||||
"A summon group must use distinct nearby positions.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RunEventEnemyTuning CreateNeutralTuning(EnemyController prefab)
|
||||
{
|
||||
return RunEventEnemyTuning.Create(
|
||||
prefab.Definition.Kind,
|
||||
1f,
|
||||
prefab.Definition.ExperienceValue,
|
||||
1f,
|
||||
Color.white,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
true);
|
||||
}
|
||||
|
||||
private static void SetPrivateField<T>(
|
||||
object target,
|
||||
string fieldName,
|
||||
T value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(
|
||||
fieldName,
|
||||
NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null, $"Could not find field {fieldName}.");
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static float GetPrivateFloat(object target, string fieldName)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(
|
||||
fieldName,
|
||||
NonPublicInstance);
|
||||
Assert.That(field, Is.Not.Null, $"Could not find field {fieldName}.");
|
||||
return (float)field.GetValue(target);
|
||||
}
|
||||
|
||||
private static int CountOwnedSummons(EnemyController owner)
|
||||
{
|
||||
int count = 0;
|
||||
EnemyController[] enemies = UnityEngine.Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None);
|
||||
for (int i = 0; i < enemies.Length; i++)
|
||||
{
|
||||
if (enemies[i].IsSummoned && enemies[i].SummonOwner == owner)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool AllTrue(bool[] values)
|
||||
{
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
if (!values[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string DescribeAnimator(
|
||||
EnemyKind kind,
|
||||
Animator animator,
|
||||
SpriteRenderer renderer,
|
||||
string reason)
|
||||
{
|
||||
AnimatorStateInfo state = animator.GetCurrentAnimatorStateInfo(0);
|
||||
return $"{kind} {reason}: state={state.fullPathHash}, "
|
||||
+ $"normalizedTime={state.normalizedTime:0.000}, "
|
||||
+ $"sprite={renderer.sprite?.name ?? "<null>"}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 223c7f5cbb4d4ee0b0a90545bff9c2c4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,886 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Audio;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.UI;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.InputSystem.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class PresentationAudioMenuTests
|
||||
{
|
||||
private const string SceneName = "CombatPrototype";
|
||||
private float originalMaster;
|
||||
private float originalMusic;
|
||||
private float originalSfx;
|
||||
private bool capturedVolumes;
|
||||
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
|
||||
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
|
||||
private bool previousRunInBackground;
|
||||
private Keyboard virtualKeyboard;
|
||||
private bool virtualKeyboardInputActive;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = true;
|
||||
Time.timeScale = 1f;
|
||||
capturedVolumes = false;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
EndVirtualKeyboardInput();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
if (service != null && capturedVolumes)
|
||||
{
|
||||
service.SetMasterVolume(originalMaster);
|
||||
service.SetMusicVolume(originalMusic);
|
||||
service.SetSfxVolume(originalSfx);
|
||||
}
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DevelopmentStartVisibility_RequiresEditorOrDevelopmentBuild()
|
||||
{
|
||||
MethodInfo predicate = typeof(RunMenuController).GetMethod(
|
||||
"IsDevelopmentStartVisible",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.That(predicate, Is.Not.Null);
|
||||
Assert.That(predicate.Invoke(null, new object[] { false, false }), Is.False);
|
||||
Assert.That(predicate.Invoke(null, new object[] { true, false }), Is.True);
|
||||
Assert.That(predicate.Invoke(null, new object[] { false, true }), Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TitleStart_UnlocksRunAfterExplicitStart()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
Assert.That(manager, Is.Not.Null);
|
||||
Assert.That(manager.IsTitleScreen, Is.True);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.False);
|
||||
Assert.That(Time.timeScale, Is.Zero);
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
try
|
||||
{
|
||||
yield return PressAndRelease(keyboard, Key.Space);
|
||||
Assert.That(manager.IsTitleScreen, Is.True,
|
||||
"Space opens the mode submenu without starting a run.");
|
||||
yield return PressAndRelease(keyboard, Key.Enter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndVirtualKeyboardInput();
|
||||
}
|
||||
Assert.That(manager.IsTitleScreen, Is.False);
|
||||
Assert.That(manager.IsProductionRun, Is.False);
|
||||
Assert.That(manager.UseDebugEventTimes, Is.True);
|
||||
Assert.That(manager.IsArtifactChargeUnlocked, Is.True);
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(rewards, Is.Not.Null);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
Assert.That(tutorials.DebugCurrentTutorial,
|
||||
Is.EqualTo(RunTutorialController.TutorialKind.Artifact));
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(tutorials.DebugCurrentTutorial,
|
||||
Is.EqualTo(RunTutorialController.TutorialKind.Guard));
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(tutorials.DebugCurrentTutorial,
|
||||
Is.EqualTo(RunTutorialController.TutorialKind.ArtifactEnhancement));
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.True);
|
||||
Assert.That(Time.timeScale, Is.EqualTo(1f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TitleProductionStart_IsSecondKeyboardItemAndLabelsFit()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
Transform menu = Object.FindAnyObjectByType<RunMenuController>()
|
||||
.transform.Find("Run Menu");
|
||||
Text title = menu.Find("Title").GetComponent<Text>();
|
||||
Assert.That(title.text, Is.EqualTo("Tiny Tackle Heroes"));
|
||||
float titlePreferredWidth = title.cachedTextGeneratorForLayout
|
||||
.GetPreferredWidth(
|
||||
title.text,
|
||||
title.GetGenerationSettings(Vector2.zero))
|
||||
/ title.pixelsPerUnit;
|
||||
Assert.That(title.cachedTextGeneratorForLayout.lineCount, Is.EqualTo(1));
|
||||
Assert.That(titlePreferredWidth,
|
||||
Is.LessThanOrEqualTo(title.rectTransform.rect.width),
|
||||
"The game title must fit in its single-line title label.");
|
||||
string[] expectedLabels = { "게임시작", "설정", "업적", "나가기" };
|
||||
for (int i = 0; i < expectedLabels.Length; i++)
|
||||
{
|
||||
Text label = menu.Find($"Button {i + 1}/Label").GetComponent<Text>();
|
||||
Assert.That(label.text, Is.EqualTo(expectedLabels[i]));
|
||||
float preferredWidth = label.cachedTextGeneratorForLayout
|
||||
.GetPreferredWidth(
|
||||
label.text,
|
||||
label.GetGenerationSettings(Vector2.zero))
|
||||
/ label.pixelsPerUnit;
|
||||
Assert.That(label.cachedTextGeneratorForLayout.lineCount, Is.EqualTo(1));
|
||||
float labelWidth = label.rectTransform.rect.width;
|
||||
Assert.That(
|
||||
preferredWidth,
|
||||
Is.LessThanOrEqualTo(labelWidth),
|
||||
$"{expectedLabels[i]} must fit in its single-line menu label.");
|
||||
}
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
try
|
||||
{
|
||||
yield return PressAndRelease(keyboard, Key.Enter);
|
||||
Assert.That(manager.IsTitleScreen, Is.True,
|
||||
"Opening the mode submenu must not begin a run.");
|
||||
|
||||
Transform submenu = menu;
|
||||
bool showDevelopment = Application.isEditor || Debug.isDebugBuild;
|
||||
var submenuLabels = new System.Collections.Generic.List<string>();
|
||||
if (showDevelopment)
|
||||
{
|
||||
submenuLabels.Add("개발용 시작");
|
||||
}
|
||||
submenuLabels.Add("기본 모드");
|
||||
submenuLabels.Add("챌린지 모드(준비 중)");
|
||||
submenuLabels.Add("하드코어 모드(준비 중)");
|
||||
submenuLabels.Add("뒤로");
|
||||
for (int i = 0; i < submenuLabels.Count; i++)
|
||||
{
|
||||
Button button = submenu.Find($"Button {i + 1}").GetComponent<Button>();
|
||||
Text label = button.GetComponentInChildren<Text>();
|
||||
Assert.That(button.gameObject.activeSelf, Is.True);
|
||||
Assert.That(label.text, Is.EqualTo(submenuLabels[i]));
|
||||
float preferredWidth = label.cachedTextGeneratorForLayout
|
||||
.GetPreferredWidth(
|
||||
label.text,
|
||||
label.GetGenerationSettings(Vector2.zero))
|
||||
/ label.pixelsPerUnit;
|
||||
Assert.That(label.cachedTextGeneratorForLayout.lineCount, Is.EqualTo(1));
|
||||
Assert.That(preferredWidth, Is.LessThanOrEqualTo(label.rectTransform.rect.width),
|
||||
$"{label.text} must fit in its single-line menu label.");
|
||||
Assert.That(button.interactable,
|
||||
Is.EqualTo(i < submenuLabels.Count - 3 || i == submenuLabels.Count - 1),
|
||||
label.text);
|
||||
}
|
||||
|
||||
int placeholderIndex = showDevelopment ? 2 : 1;
|
||||
for (int i = placeholderIndex; i < placeholderIndex + 2; i++)
|
||||
{
|
||||
submenu.Find($"Button {i + 1}").GetComponent<Button>().onClick.Invoke();
|
||||
Assert.That(manager.IsTitleScreen, Is.True,
|
||||
"A preparation placeholder cannot launch a run.");
|
||||
}
|
||||
|
||||
if (showDevelopment)
|
||||
{
|
||||
yield return PressAndRelease(keyboard, Key.DownArrow);
|
||||
}
|
||||
Assert.That(
|
||||
EventSystem.current.currentSelectedGameObject
|
||||
.GetComponentInChildren<Text>().text,
|
||||
Is.EqualTo("기본 모드"));
|
||||
yield return PressAndRelease(keyboard, Key.DownArrow);
|
||||
Assert.That(
|
||||
EventSystem.current.currentSelectedGameObject
|
||||
.GetComponentInChildren<Text>().text,
|
||||
Is.EqualTo("뒤로"),
|
||||
"Keyboard navigation skips disabled mode placeholders.");
|
||||
yield return PressAndRelease(keyboard, Key.UpArrow);
|
||||
Assert.That(
|
||||
EventSystem.current.currentSelectedGameObject
|
||||
.GetComponentInChildren<Text>().text,
|
||||
Is.EqualTo("기본 모드"));
|
||||
yield return PressAndRelease(keyboard, Key.Enter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndVirtualKeyboardInput();
|
||||
}
|
||||
|
||||
Assert.That(manager.IsTitleScreen, Is.False);
|
||||
Assert.That(manager.IsProductionRun, Is.True);
|
||||
Assert.That(manager.UseDebugEventTimes, Is.False);
|
||||
Assert.That(manager.IsGuardUnlocked, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator UiInitialization_ReusesEventSystemAndPreservesPointerModule()
|
||||
{
|
||||
yield return LoadScene();
|
||||
EventSystem original = EventSystem.current;
|
||||
Assert.That(original, Is.Not.Null);
|
||||
Object.Destroy(original.gameObject);
|
||||
yield return null;
|
||||
|
||||
EnsurePresentationEventSystem(false);
|
||||
EventSystem eventSystem = EventSystem.current;
|
||||
Assert.That(eventSystem, Is.Not.Null);
|
||||
Assert.That(CountActiveSceneEventSystems(), Is.EqualTo(1));
|
||||
InputSystemUIInputModule module =
|
||||
eventSystem.GetComponent<InputSystemUIInputModule>();
|
||||
Assert.That(module, Is.Not.Null);
|
||||
Assert.That(module.point, Is.Not.Null);
|
||||
Assert.That(module.leftClick, Is.Not.Null);
|
||||
Assert.That(module.scrollWheel, Is.Not.Null);
|
||||
Assert.That(module.move, Is.Not.Null);
|
||||
Assert.That(module.submit, Is.Not.Null);
|
||||
Assert.That(module.cancel, Is.Not.Null);
|
||||
var pointAction = module.point;
|
||||
var clickAction = module.leftClick;
|
||||
var scrollAction = module.scrollWheel;
|
||||
var moveAction = module.move;
|
||||
var submitAction = module.submit;
|
||||
var cancelAction = module.cancel;
|
||||
|
||||
// Exercise HUD -> menu -> HUD initialization, including repeats.
|
||||
EnsurePresentationEventSystem(false);
|
||||
Assert.That(module.move, Is.SameAs(moveAction));
|
||||
Assert.That(module.submit, Is.SameAs(submitAction));
|
||||
Assert.That(module.cancel, Is.SameAs(cancelAction));
|
||||
EnsurePresentationEventSystem(true);
|
||||
EnsurePresentationEventSystem(false);
|
||||
Assert.That(EventSystem.current, Is.SameAs(eventSystem));
|
||||
Assert.That(CountActiveSceneEventSystems(), Is.EqualTo(1));
|
||||
Assert.That(eventSystem.GetComponents<BaseInputModule>(),
|
||||
Has.Length.EqualTo(1));
|
||||
Assert.That(eventSystem.GetComponent<InputSystemUIInputModule>(),
|
||||
Is.SameAs(module));
|
||||
Assert.That(module.move, Is.Null);
|
||||
Assert.That(module.submit, Is.Null);
|
||||
Assert.That(module.cancel, Is.Null);
|
||||
Assert.That(module.point, Is.SameAs(pointAction));
|
||||
Assert.That(module.leftClick, Is.SameAs(clickAction));
|
||||
Assert.That(module.scrollWheel, Is.SameAs(scrollAction));
|
||||
|
||||
Object.Destroy(module);
|
||||
yield return null;
|
||||
StandaloneInputModule existingModule =
|
||||
eventSystem.gameObject.AddComponent<StandaloneInputModule>();
|
||||
existingModule.enabled = false;
|
||||
EnsurePresentationEventSystem(false);
|
||||
EnsurePresentationEventSystem(true);
|
||||
Assert.That(eventSystem.GetComponent<BaseInputModule>(),
|
||||
Is.SameAs(existingModule));
|
||||
Assert.That(eventSystem.GetComponent<InputSystemUIInputModule>(),
|
||||
Is.Null);
|
||||
Assert.That(CountActiveSceneEventSystems(), Is.EqualTo(1));
|
||||
|
||||
InputSystemUIInputModule secondaryModule =
|
||||
eventSystem.gameObject.AddComponent<InputSystemUIInputModule>();
|
||||
var secondaryPoint = secondaryModule.point;
|
||||
var secondaryClick = secondaryModule.leftClick;
|
||||
var secondaryScroll = secondaryModule.scrollWheel;
|
||||
EnsurePresentationEventSystem(false);
|
||||
EnsurePresentationEventSystem(true);
|
||||
EnsurePresentationEventSystem(false);
|
||||
Assert.That(eventSystem.GetComponent<BaseInputModule>(),
|
||||
Is.SameAs(existingModule));
|
||||
Assert.That(eventSystem.GetComponent<InputSystemUIInputModule>(),
|
||||
Is.SameAs(secondaryModule));
|
||||
Assert.That(eventSystem.GetComponents<BaseInputModule>(),
|
||||
Has.Length.EqualTo(2));
|
||||
Assert.That(secondaryModule.move, Is.Null);
|
||||
Assert.That(secondaryModule.submit, Is.Null);
|
||||
Assert.That(secondaryModule.cancel, Is.Null);
|
||||
Assert.That(secondaryModule.point, Is.SameAs(secondaryPoint));
|
||||
Assert.That(secondaryModule.leftClick, Is.SameAs(secondaryClick));
|
||||
Assert.That(secondaryModule.scrollWheel, Is.SameAs(secondaryScroll));
|
||||
|
||||
Object.Destroy(secondaryModule);
|
||||
Object.Destroy(existingModule);
|
||||
yield return null;
|
||||
EnsurePresentationEventSystem(false);
|
||||
InputSystemUIInputModule restoredModule =
|
||||
eventSystem.GetComponent<InputSystemUIInputModule>();
|
||||
Assert.That(restoredModule, Is.Not.Null);
|
||||
Assert.That(eventSystem.GetComponents<BaseInputModule>(),
|
||||
Has.Length.EqualTo(1));
|
||||
Assert.That(CountActiveSceneEventSystems(), Is.EqualTo(1));
|
||||
EnsurePresentationEventSystem(true);
|
||||
Assert.That(restoredModule.move, Is.Null);
|
||||
Assert.That(restoredModule.submit, Is.Null);
|
||||
Assert.That(restoredModule.cancel, Is.Null);
|
||||
Assert.That(restoredModule.point, Is.Not.Null);
|
||||
Assert.That(restoredModule.leftClick, Is.Not.Null);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ArtifactSelection_SharedCardsKeepIconAndTextLayout()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = true;
|
||||
yield return LoadScene();
|
||||
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(manager.BeginRun(), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
|
||||
GameObject panel = GameObject.Find("Artifact Reward Selection");
|
||||
Text optionText = panel.transform.Find("Option 1").GetComponent<Text>();
|
||||
Image card = panel.transform.Find("Option 1 Card").GetComponent<Image>();
|
||||
Image icon = panel.transform.Find("Option 1 Icon").GetComponent<Image>();
|
||||
RectTransform textRect = optionText.rectTransform;
|
||||
RectTransform cardRect = card.rectTransform;
|
||||
RectTransform iconRect = icon.rectTransform;
|
||||
Assert.That(panel.GetComponent<Image>().type, Is.EqualTo(Image.Type.Sliced));
|
||||
Assert.That(optionText.alignment, Is.EqualTo(TextAnchor.MiddleCenter));
|
||||
Assert.That(optionText.font, Is.SameAs(
|
||||
Resources.Load<Font>("Presentation/Fonts/Galmuri9")));
|
||||
Assert.That(optionText.raycastTarget, Is.False);
|
||||
Assert.That(textRect.sizeDelta, Is.EqualTo(new Vector2(380f, 64f)));
|
||||
Assert.That(cardRect.anchorMin, Is.EqualTo(textRect.anchorMin));
|
||||
Assert.That(cardRect.anchorMax, Is.EqualTo(textRect.anchorMax));
|
||||
Assert.That(cardRect.pivot, Is.EqualTo(textRect.pivot));
|
||||
Assert.That(cardRect.anchoredPosition,
|
||||
Is.EqualTo(textRect.anchoredPosition + Vector2.up * 25f));
|
||||
Assert.That(cardRect.sizeDelta, Is.EqualTo(new Vector2(380f, 128f)));
|
||||
Assert.That(card.transform.GetSiblingIndex(),
|
||||
Is.EqualTo(optionText.transform.GetSiblingIndex() - 2));
|
||||
Assert.That(icon.transform.GetSiblingIndex(),
|
||||
Is.EqualTo(optionText.transform.GetSiblingIndex() - 1));
|
||||
Assert.That(card.raycastTarget, Is.False);
|
||||
Assert.That(icon.preserveAspect, Is.True);
|
||||
Assert.That(icon.raycastTarget, Is.False);
|
||||
Assert.That(iconRect.anchorMin, Is.EqualTo(textRect.anchorMin));
|
||||
Assert.That(iconRect.anchorMax, Is.EqualTo(textRect.anchorMax));
|
||||
Assert.That(iconRect.pivot, Is.EqualTo(textRect.pivot));
|
||||
Assert.That(iconRect.anchoredPosition,
|
||||
Is.EqualTo(textRect.anchoredPosition + Vector2.up * 45f));
|
||||
Assert.That(iconRect.sizeDelta, Is.EqualTo(new Vector2(48f, 48f)));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator MenuKeyboard_SettingsSliderAndBack_AreSingleStep()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
try
|
||||
{
|
||||
yield return PressAndRelease(keyboard, Key.DownArrow);
|
||||
GameObject selected = EventSystem.current.currentSelectedGameObject;
|
||||
Assert.That(selected, Is.Not.Null);
|
||||
Assert.That(selected.GetComponentInChildren<Text>().text, Is.EqualTo("설정"));
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.Enter);
|
||||
Slider slider = GameObject.Find("Master Slider").GetComponent<Slider>();
|
||||
Assert.That(slider.gameObject.activeSelf, Is.True);
|
||||
slider.value = 0.5f;
|
||||
float before = slider.value;
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.RightArrow);
|
||||
Assert.That(slider.value, Is.EqualTo(before + 0.05f).Within(0.001f));
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.Escape);
|
||||
Assert.That(slider.gameObject.activeSelf, Is.False);
|
||||
Assert.That(manager.IsTitleScreen, Is.True);
|
||||
Assert.That(service.MasterVolume, Is.EqualTo(slider.value).Within(0.001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndVirtualKeyboardInput();
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SettingsFocus_IsVisibleAndPointerBackSyncsKeyboardSelection()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
RunMenuController menuController = Object.FindAnyObjectByType<RunMenuController>();
|
||||
Assert.That(menuController, Is.Not.Null);
|
||||
Transform menu = menuController.transform.Find("Run Menu");
|
||||
Text inputHelp = menu.Find("Menu Input Help").GetComponent<Text>();
|
||||
Assert.That(inputHelp.text, Is.EqualTo("항목 이동: 위/아래 선택: Enter/Space"));
|
||||
Assert.That(inputHelp.rectTransform.anchorMin, Is.EqualTo(Vector2.zero));
|
||||
Assert.That(inputHelp.alignment, Is.EqualTo(TextAnchor.MiddleLeft));
|
||||
Assert.That(menu.Find("Button 1/Focus Marker").gameObject.activeSelf, Is.True);
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
try
|
||||
{
|
||||
menu.Find("Button 2").GetComponent<Button>().onClick.Invoke();
|
||||
yield return null;
|
||||
Assert.That(inputHelp.text, Is.EqualTo(
|
||||
"항목 이동: 위/아래 음량 조절: 왼쪽/오른쪽\n뒤로 선택: Enter/Space 즉시 뒤로: Esc"));
|
||||
|
||||
GameObject masterFocus = menu.Find("Settings Focus 0").gameObject;
|
||||
GameObject musicFocus = menu.Find("Settings Focus 1").gameObject;
|
||||
GameObject backFocus = menu.Find("Settings Focus 3").gameObject;
|
||||
Slider masterSlider = menu.Find("Master Slider").GetComponent<Slider>();
|
||||
Button backButton = menu.Find("Button 4").GetComponent<Button>();
|
||||
Assert.That(masterFocus.activeSelf, Is.True);
|
||||
Assert.That(EventSystem.current.currentSelectedGameObject, Is.EqualTo(masterSlider.gameObject));
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.DownArrow);
|
||||
Assert.That(masterFocus.activeSelf, Is.False);
|
||||
Assert.That(musicFocus.activeSelf, Is.True);
|
||||
|
||||
ExecuteEvents.Execute(
|
||||
backButton.gameObject,
|
||||
new PointerEventData(EventSystem.current),
|
||||
ExecuteEvents.pointerEnterHandler);
|
||||
Assert.That(EventSystem.current.currentSelectedGameObject, Is.EqualTo(backButton.gameObject));
|
||||
Assert.That(backFocus.activeSelf, Is.True);
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.DownArrow);
|
||||
Assert.That(masterFocus.activeSelf, Is.True);
|
||||
Assert.That(backFocus.activeSelf, Is.False);
|
||||
Assert.That(EventSystem.current.currentSelectedGameObject, Is.EqualTo(masterSlider.gameObject));
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.UpArrow);
|
||||
yield return PressAndRelease(keyboard, Key.Enter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndVirtualKeyboardInput();
|
||||
}
|
||||
|
||||
Assert.That(manager.IsTitleScreen, Is.True);
|
||||
Assert.That(inputHelp.text, Is.EqualTo("항목 이동: 위/아래 선택: Enter/Space"));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PauseAndSelection_KeepEachOtherInterlocked()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
Assert.That(manager.BeginRun(), Is.True);
|
||||
yield return null;
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
|
||||
CloseArtifactTutorialModal(manager);
|
||||
|
||||
GameObject ownerObject = new("Selection Owner Test");
|
||||
SelectionOwner owner = ownerObject.AddComponent<SelectionOwner>();
|
||||
try
|
||||
{
|
||||
Assert.That(manager.TryOpenSelection(owner), Is.True);
|
||||
Assert.That(manager.TryOpenPause(), Is.False);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.False);
|
||||
Assert.That(manager.CloseSelection(owner), Is.True);
|
||||
Assert.That(manager.TryOpenPause(), Is.True);
|
||||
Text inputHelp = GameObject.Find("Menu Input Help").GetComponent<Text>();
|
||||
Assert.That(inputHelp.text, Is.EqualTo(
|
||||
"항목 이동: 위/아래 선택: Enter/Space 계속: Esc"));
|
||||
Transform menu = Object.FindAnyObjectByType<RunMenuController>().transform.Find("Run Menu");
|
||||
Assert.That(menu.Find("Button 1/Focus Marker").gameObject.activeSelf, Is.True);
|
||||
Assert.That(manager.TryOpenSelection(owner), Is.False);
|
||||
Assert.That(manager.ClosePause(), Is.True);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.Destroy(ownerObject);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator StartupSelection_RecoversWhenPausedBeforeItsFirstFrame()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(manager, Is.Not.Null);
|
||||
Assert.That(rewards, Is.Not.Null);
|
||||
|
||||
Assert.That(manager.BeginRun(), Is.True);
|
||||
Assert.That(manager.TryOpenPause(), Is.True);
|
||||
Assert.That(manager.IsPaused, Is.True);
|
||||
|
||||
// The startup request is queued by BeginRun. A paused frame must
|
||||
// leave it pending so resuming can open the first color step.
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.False);
|
||||
Assert.That(manager.IsSelectionOpen, Is.False);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.False);
|
||||
|
||||
Assert.That(manager.ClosePause(), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugShownCount, Is.EqualTo(2));
|
||||
Assert.That(
|
||||
rewards.DebugGetShownArtifact(0).ArtifactColor,
|
||||
Is.EqualTo(ArtifactColor.Green));
|
||||
Assert.That(
|
||||
rewards.DebugGetShownArtifact(1).ArtifactColor,
|
||||
Is.EqualTo(ArtifactColor.Green));
|
||||
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugShownCount, Is.EqualTo(2));
|
||||
Assert.That(
|
||||
rewards.DebugGetShownArtifact(0).ArtifactColor,
|
||||
Is.EqualTo(ArtifactColor.Red));
|
||||
Assert.That(
|
||||
rewards.DebugGetShownArtifact(1).ArtifactColor,
|
||||
Is.EqualTo(ArtifactColor.Red));
|
||||
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugShownCount, Is.EqualTo(2));
|
||||
Assert.That(
|
||||
rewards.DebugGetShownArtifact(0).ArtifactColor,
|
||||
Is.EqualTo(ArtifactColor.Blue));
|
||||
Assert.That(
|
||||
rewards.DebugGetShownArtifact(1).ArtifactColor,
|
||||
Is.EqualTo(ArtifactColor.Blue));
|
||||
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
|
||||
CloseArtifactTutorialModal(manager);
|
||||
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.False);
|
||||
Assert.That(manager.IsSelectionOpen, Is.False);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.True);
|
||||
Assert.That(
|
||||
Object.FindAnyObjectByType<ActiveArtifactController>().OwnedArtifactCount,
|
||||
Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AudioService_LoadsContractClipsAndCapsVoices()
|
||||
{
|
||||
yield return LoadScene();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
Assert.That(service, Is.Not.Null);
|
||||
Assert.That(service.VoiceCount, Is.EqualTo(8));
|
||||
string[] ids =
|
||||
{
|
||||
"ui_move", "ui_confirm", "hit_light", "hit_heavy",
|
||||
"player_hurt", "enemy_defeat", "xp_pickup", "level_up",
|
||||
"gauge_ready", "run_end", "charge_start", "artifact_dash",
|
||||
"artifact_pulse", "artifact_ray", "artifact_cyclone",
|
||||
"artifact_thunder", "artifact_arc", "bgm_courtyard", "bgm_boss",
|
||||
};
|
||||
for (int i = 0; i < ids.Length; i++)
|
||||
{
|
||||
Assert.That(service.DebugHasClip(ids[i]), Is.True, ids[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator VolumeSettings_PersistNormalizedValues()
|
||||
{
|
||||
yield return LoadScene();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
service.SetMasterVolume(0.31f);
|
||||
service.SetMusicVolume(0.62f);
|
||||
service.SetSfxVolume(0.47f);
|
||||
Assert.That(service.MasterVolume, Is.EqualTo(0.31f).Within(0.0001f));
|
||||
Assert.That(service.MusicVolume, Is.EqualTo(0.62f).Within(0.0001f));
|
||||
Assert.That(service.SfxVolume, Is.EqualTo(0.47f).Within(0.0001f));
|
||||
Assert.That(PlayerPrefs.GetFloat("BumpCombat.MasterVolume"), Is.EqualTo(0.31f).Within(0.0001f));
|
||||
Assert.That(PlayerPrefs.GetFloat("BumpCombat.MusicVolume"), Is.EqualTo(0.62f).Within(0.0001f));
|
||||
Assert.That(PlayerPrefs.GetFloat("BumpCombat.SfxVolume"), Is.EqualTo(0.47f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SuccessfulArtifactCast_UsesArtifactClipOnce()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
yield return LoadScene();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
ActiveArtifactController artifacts =
|
||||
Object.FindAnyObjectByType<ActiveArtifactController>();
|
||||
artifacts.AddMovementCharge(20f);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(service.LastPlayedClipId, Is.EqualTo("artifact_dash"));
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.False);
|
||||
Assert.That(service.LastPlayedClipId, Is.EqualTo("artifact_dash"));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator BossSpawn_SwitchesToBossMusic()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
yield return LoadScene();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
GameObject midBossButtonObject = GameObject.Find("Debug Spawn MidBoss");
|
||||
GameObject finalBossButtonObject = GameObject.Find("Debug Spawn FinalBoss");
|
||||
Assert.That(midBossButtonObject, Is.Not.Null);
|
||||
Assert.That(finalBossButtonObject, Is.Not.Null);
|
||||
Button midBossButton = midBossButtonObject.GetComponent<Button>();
|
||||
Button finalBossButton = finalBossButtonObject.GetComponent<Button>();
|
||||
Assert.That(CountPlayingMusicSources(), Is.EqualTo(1));
|
||||
Assert.That(midBossButton, Is.Not.Null);
|
||||
Assert.That(finalBossButton, Is.Not.Null);
|
||||
midBossButton.onClick.Invoke();
|
||||
Assert.That(service.ActiveMusicClipId, Is.EqualTo("bgm_boss"));
|
||||
Assert.That(
|
||||
CountPlayingMusicSources(),
|
||||
Is.EqualTo(1),
|
||||
"Starting boss music must stop ambient music before the fade-in.");
|
||||
yield return null;
|
||||
Assert.That(CountPlayingMusicSources(), Is.EqualTo(1));
|
||||
|
||||
finalBossButton.onClick.Invoke();
|
||||
Assert.That(service.ActiveMusicClipId, Is.EqualTo("bgm_boss"));
|
||||
Assert.That(CountPlayingMusicSources(), Is.EqualTo(1));
|
||||
yield return null;
|
||||
Assert.That(CountPlayingMusicSources(), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator AudioService_DestroyAndReadd_ClearsLegacyMusicChildren()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
yield return LoadScene();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
Canvas canvas = service.GetComponentInParent<Canvas>();
|
||||
GameObject legacyObject = new("Music A");
|
||||
legacyObject.transform.SetParent(service.transform, false);
|
||||
AudioSource legacySource = legacyObject.AddComponent<AudioSource>();
|
||||
AudioClip legacyClip = AudioClip.Create("Legacy Music", 4410, 1, 44100, false);
|
||||
legacySource.clip = legacyClip;
|
||||
legacySource.loop = true;
|
||||
legacySource.Play();
|
||||
Assert.That(CountPlayingMusicSources(), Is.EqualTo(2));
|
||||
|
||||
Object.Destroy(service);
|
||||
yield return null;
|
||||
Assert.That(CountPlayingMusicSources(), Is.Zero);
|
||||
|
||||
canvas.gameObject.AddComponent<BumpCombatAudioService>();
|
||||
yield return null;
|
||||
Assert.That(CountPlayingMusicSources(), Is.EqualTo(1));
|
||||
Object.Destroy(legacyClip);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator MusicDuckAndGameOverFade_StopsMusicSource()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = true;
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
float titleGain = service.CurrentMusicOutputGain;
|
||||
Assert.That(titleGain, Is.GreaterThan(0f));
|
||||
|
||||
Assert.That(manager.BeginRun(), Is.True);
|
||||
yield return null;
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
|
||||
CloseArtifactTutorialModal(manager);
|
||||
|
||||
Assert.That(service.CurrentMusicOutputGain, Is.GreaterThan(titleGain));
|
||||
|
||||
service.SetMasterVolume(0f);
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
Assert.That(director.TryDebugSpawnEventEnemy(RunTimedEvent.MidBoss), Is.True);
|
||||
yield return null;
|
||||
Assert.That(service.CurrentMusicOutputGain, Is.Zero);
|
||||
|
||||
manager.EndRun();
|
||||
yield return new WaitForSecondsRealtime(0.6f);
|
||||
Assert.That(service.AreMusicSourcesStopped, Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SpawnDirector_DoesNotWaveSpawnWhileTitleIsBlocked()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = true;
|
||||
yield return LoadScene();
|
||||
RunManager manager = Object.FindAnyObjectByType<RunManager>();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
Assert.That(manager.IsTitleScreen, Is.True);
|
||||
typeof(SpawnDirector)
|
||||
.GetField("nextWaveTime", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(director, -1f);
|
||||
yield return null;
|
||||
Assert.That(EnemyController.AliveCount, Is.Zero);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.False);
|
||||
}
|
||||
|
||||
private IEnumerator LoadScene()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync(SceneName);
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
yield return null;
|
||||
BumpCombatAudioService service =
|
||||
Object.FindAnyObjectByType<BumpCombatAudioService>();
|
||||
if (service != null && !capturedVolumes)
|
||||
{
|
||||
originalMaster = service.MasterVolume;
|
||||
originalMusic = service.MusicVolume;
|
||||
originalSfx = service.SfxVolume;
|
||||
capturedVolumes = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator PressAndRelease(Keyboard keyboard, Key key)
|
||||
{
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(key));
|
||||
yield return null;
|
||||
Assert.That(keyboard[key].isPressed, Is.True);
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(keyboard[key].isPressed, Is.False);
|
||||
}
|
||||
|
||||
private static void EnsurePresentationEventSystem(
|
||||
bool clearMenuNavigationActions)
|
||||
{
|
||||
System.Type styleType = typeof(RunHUD).Assembly.GetType(
|
||||
"BumpCombat.UI.PresentationUiStyle",
|
||||
true);
|
||||
MethodInfo ensureMethod = styleType.GetMethod(
|
||||
"EnsureEventSystem",
|
||||
BindingFlags.Public | BindingFlags.Static);
|
||||
Assert.That(ensureMethod, Is.Not.Null);
|
||||
ensureMethod.Invoke(null, new object[] { clearMenuNavigationActions });
|
||||
}
|
||||
|
||||
private static void CloseArtifactTutorialModal(RunManager manager)
|
||||
{
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
Assert.That(tutorials, Is.Not.Null);
|
||||
Assert.That(tutorials.DebugIsVisible, Is.True,
|
||||
"The first acquired artifact opens its tutorial modal before the next interaction.");
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(manager.IsSelectionOpen, Is.False);
|
||||
}
|
||||
|
||||
private static int CountActiveSceneEventSystems()
|
||||
{
|
||||
int count = 0;
|
||||
GameObject[] roots = SceneManager.GetActiveScene().GetRootGameObjects();
|
||||
foreach (GameObject root in roots)
|
||||
{
|
||||
count += root.GetComponentsInChildren<EventSystem>(true).Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountPlayingMusicSources()
|
||||
{
|
||||
AudioSource[] sources = Object.FindObjectsByType<AudioSource>(
|
||||
FindObjectsInactive.Include,
|
||||
FindObjectsSortMode.None);
|
||||
int playingCount = 0;
|
||||
for (int i = 0; i < sources.Length; i++)
|
||||
{
|
||||
if (sources[i].name.StartsWith("Music", System.StringComparison.Ordinal)
|
||||
&& sources[i].isPlaying)
|
||||
{
|
||||
playingCount++;
|
||||
}
|
||||
}
|
||||
return playingCount;
|
||||
}
|
||||
|
||||
private Keyboard BeginVirtualKeyboardInput()
|
||||
{
|
||||
Assert.That(virtualKeyboardInputActive, Is.False);
|
||||
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
|
||||
previousEditorInputBehavior = InputSystem.settings.editorInputBehaviorInPlayMode;
|
||||
previousRunInBackground = Application.runInBackground;
|
||||
InputSystem.settings.backgroundBehavior =
|
||||
InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
Application.runInBackground = true;
|
||||
virtualKeyboard = InputSystem.AddDevice<Keyboard>();
|
||||
virtualKeyboard.MakeCurrent();
|
||||
Assert.That(Keyboard.current, Is.SameAs(virtualKeyboard));
|
||||
virtualKeyboardInputActive = true;
|
||||
return virtualKeyboard;
|
||||
}
|
||||
|
||||
private void EndVirtualKeyboardInput()
|
||||
{
|
||||
if (!virtualKeyboardInputActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (virtualKeyboard != null && virtualKeyboard.added)
|
||||
{
|
||||
InputSystem.RemoveDevice(virtualKeyboard);
|
||||
}
|
||||
|
||||
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode = previousEditorInputBehavior;
|
||||
Application.runInBackground = previousRunInBackground;
|
||||
virtualKeyboard = null;
|
||||
virtualKeyboardInputActive = false;
|
||||
}
|
||||
|
||||
private sealed class SelectionOwner : MonoBehaviour
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4a8c26d719b44d9a5f3c0e7b1d2a684
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class PressureStreakPlayModeTests
|
||||
{
|
||||
private const BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private readonly List<GameObject> testTargets = new();
|
||||
private bool previousGrantCatalogForTests;
|
||||
private bool previousProductionModeForTests;
|
||||
private float previousTimeScale;
|
||||
private PlayerStats playerStats;
|
||||
private RunManager runManager;
|
||||
private SpawnDirector spawnDirector;
|
||||
private EnemyController ordinarySceneTarget;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
|
||||
previousProductionModeForTests = RunManager.ForceProductionModeForTests;
|
||||
previousTimeScale = Time.timeScale;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (GameObject target in testTargets)
|
||||
{
|
||||
if (target != null)
|
||||
{
|
||||
Object.Destroy(target);
|
||||
}
|
||||
}
|
||||
|
||||
testTargets.Clear();
|
||||
ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests;
|
||||
RunManager.ForceProductionModeForTests = previousProductionModeForTests;
|
||||
Time.timeScale = previousTimeScale;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TwoKillsDoNotApply_ThirdKillAppliesTwentyPercent()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
SetRunTime(100f);
|
||||
EnemyController first = CreateDeadEnemy();
|
||||
yield return null;
|
||||
RaiseOrdinaryHit(first);
|
||||
Assert.That(playerStats.PressureStreakRemaining, Is.GreaterThan(0f));
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.False);
|
||||
|
||||
SetRunTime(101f);
|
||||
EnemyController second = CreateDeadEnemy();
|
||||
RaiseOrdinaryHit(second);
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(2));
|
||||
Assert.That(playerStats.PressureStreakRemaining, Is.GreaterThan(0f));
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.False);
|
||||
|
||||
EnemyController third = CreateDeadEnemy();
|
||||
yield return null;
|
||||
RaiseOrdinaryHit(third);
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(3));
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.True);
|
||||
Assert.That(playerStats.MoveSpeed, Is.EqualTo(3.6f).Within(0.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ConsecutiveWindow_UsesKillToKillGap_AndFourthRefreshesWithoutStacking()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
SetRunTime(200f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
SetRunTime(202f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
SetRunTime(204f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.True);
|
||||
|
||||
SetRunTime(205f);
|
||||
playerStats.RefreshPressureStreak();
|
||||
float beforeRefresh = playerStats.PressureStreakRemaining;
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(4));
|
||||
Assert.That(
|
||||
playerStats.GetStackCount("player.pressure-streak", CharacterStat.MoveSpeed),
|
||||
Is.EqualTo(1));
|
||||
Assert.That(playerStats.MoveSpeed, Is.EqualTo(3.6f).Within(0.001f));
|
||||
Assert.That(playerStats.PressureStreakRemaining, Is.GreaterThan(beforeRefresh));
|
||||
|
||||
SetRunTime(208.01f);
|
||||
playerStats.RefreshPressureStreak();
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(0));
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.False);
|
||||
|
||||
SetRunTime(220f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
SetRunTime(223f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
SetRunTime(226f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(3));
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.True);
|
||||
|
||||
SetRunTime(230.01f);
|
||||
playerStats.RefreshPressureStreak();
|
||||
SetRunTime(240f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
SetRunTime(243.01f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
SetRunTime(246.02f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(1));
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ArtifactDashNonlethalAndRetiredEvents_DoNotCount()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
EnemyController artifact = CreateDeadEnemy();
|
||||
EnemyController dash = CreateDeadEnemy();
|
||||
EnemyController nonlethal = CreateLivingEnemy();
|
||||
EnemyController retired = CreateDeadEnemy();
|
||||
yield return null;
|
||||
|
||||
CombatEvents.RaiseValidHit(CreateHit(
|
||||
artifact.gameObject,
|
||||
isArtifactHit: true,
|
||||
sourceId: "artifact.pulse"));
|
||||
CombatEvents.RaiseValidHit(CreateHit(
|
||||
dash.gameObject,
|
||||
isDash: true));
|
||||
RaiseOrdinaryHit(nonlethal);
|
||||
CombatEvents.RaiseEnemyRetired(retired.gameObject);
|
||||
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.Zero);
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SameEnemyLifetime_IsCountedOnce_AndOtherModifiersRemainOwned()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
playerStats.AddModifier(new StatModifier(
|
||||
"level-up.move-speed",
|
||||
CharacterStat.MoveSpeed,
|
||||
ModifierOperation.Increased,
|
||||
0.1f));
|
||||
SetRunTime(300f);
|
||||
EnemyController target = CreateDeadEnemy();
|
||||
yield return null;
|
||||
RaiseOrdinaryHit(target);
|
||||
RaiseOrdinaryHit(target);
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(1));
|
||||
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
Assert.That(playerStats.MoveSpeed, Is.EqualTo(3.9f).Within(0.001f));
|
||||
|
||||
SetRunTime(304.01f);
|
||||
playerStats.RefreshPressureStreak();
|
||||
Assert.That(playerStats.GetStackCount(
|
||||
"level-up.move-speed",
|
||||
CharacterStat.MoveSpeed), Is.EqualTo(1));
|
||||
Assert.That(playerStats.GetStackCount(
|
||||
"player.pressure-streak",
|
||||
CharacterStat.MoveSpeed), Is.Zero);
|
||||
Assert.That(playerStats.MoveSpeed, Is.EqualTo(3.3f).Within(0.001f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PauseAndDisable_DoNotConsumeOrRetainPressureStreak()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
SetRunTime(400f);
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
RaiseOrdinaryHit(CreateDeadEnemy());
|
||||
float remainingBeforePause = playerStats.PressureStreakRemaining;
|
||||
runManager.SetSelectionOpen(true);
|
||||
yield return new WaitForSecondsRealtime(0.2f);
|
||||
playerStats.RefreshPressureStreak();
|
||||
Assert.That(
|
||||
playerStats.PressureStreakRemaining,
|
||||
Is.EqualTo(remainingBeforePause).Within(0.001f));
|
||||
runManager.SetSelectionOpen(false);
|
||||
|
||||
playerStats.enabled = false;
|
||||
Assert.That(playerStats.GetStackCount(
|
||||
"player.pressure-streak",
|
||||
CharacterStat.MoveSpeed), Is.Zero);
|
||||
playerStats.enabled = true;
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.Zero);
|
||||
Assert.That(playerStats.IsPressureStreakActive, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LethalResolverBump_RaisesOrdinaryDeathHitForTheStreak()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
|
||||
if (ordinarySceneTarget == null)
|
||||
{
|
||||
Assert.That(spawnDirector, Is.Not.Null);
|
||||
spawnDirector.DebugSpawnImmediate(1);
|
||||
yield return null;
|
||||
ordinarySceneTarget = FindOrdinarySceneTarget();
|
||||
}
|
||||
|
||||
Assert.That(ordinarySceneTarget, Is.Not.Null);
|
||||
ordinarySceneTarget.gameObject.SetActive(true);
|
||||
yield return null;
|
||||
SetEnemyState(ordinarySceneTarget, EnemyState.Chase);
|
||||
FieldInfo healthBackingField = typeof(EnemyController).GetField(
|
||||
"<CurrentHealth>k__BackingField",
|
||||
NonPublicInstance);
|
||||
Assert.That(healthBackingField, Is.Not.Null);
|
||||
healthBackingField.SetValue(ordinarySceneTarget, 1f);
|
||||
|
||||
BumpCombatResolver resolver =
|
||||
playerStats.GetComponent<BumpCombatResolver>();
|
||||
Assert.That(resolver, Is.Not.Null);
|
||||
MethodInfo resolveEnemy = typeof(BumpCombatResolver).GetMethod(
|
||||
"ResolveEnemy",
|
||||
NonPublicInstance);
|
||||
Assert.That(resolveEnemy, Is.Not.Null);
|
||||
object result = resolveEnemy.Invoke(
|
||||
resolver,
|
||||
new object[]
|
||||
{
|
||||
ordinarySceneTarget,
|
||||
false,
|
||||
(Vector2)ordinarySceneTarget.transform.position,
|
||||
(Vector2)playerStats.transform.position,
|
||||
Vector2.right,
|
||||
HitSide.Front,
|
||||
false,
|
||||
100f,
|
||||
null,
|
||||
null,
|
||||
DamageTag.Collision,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
});
|
||||
|
||||
Assert.That(result, Is.EqualTo(true));
|
||||
Assert.That(ordinarySceneTarget.IsDead, Is.True);
|
||||
Assert.That(playerStats.PressureStreakKillCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
private IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
yield return null;
|
||||
runManager = RunManager.Instance;
|
||||
playerStats = Object.FindAnyObjectByType<PlayerStats>();
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
Assert.That(playerStats, Is.Not.Null);
|
||||
|
||||
spawnDirector = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (spawnDirector != null)
|
||||
{
|
||||
spawnDirector.enabled = false;
|
||||
}
|
||||
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
if (ordinarySceneTarget == null
|
||||
&& !enemy.IsEventEnemy
|
||||
&& !enemy.IsDead)
|
||||
{
|
||||
ordinarySceneTarget = enemy;
|
||||
}
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
SetRunTime(100f);
|
||||
playerStats.RefreshPressureStreak();
|
||||
}
|
||||
|
||||
private EnemyController CreateDeadEnemy()
|
||||
{
|
||||
GameObject target = new(
|
||||
"Pressure Streak Dead Target",
|
||||
typeof(BoxCollider2D));
|
||||
testTargets.Add(target);
|
||||
EnemyController enemy = target.AddComponent<EnemyController>();
|
||||
SetEnemyState(enemy, EnemyState.Dead);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private EnemyController CreateLivingEnemy()
|
||||
{
|
||||
GameObject target = new(
|
||||
"Pressure Streak Living Target",
|
||||
typeof(BoxCollider2D));
|
||||
testTargets.Add(target);
|
||||
EnemyController enemy = target.AddComponent<EnemyController>();
|
||||
SetEnemyState(enemy, EnemyState.Chase);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private void RaiseOrdinaryHit(EnemyController enemy)
|
||||
{
|
||||
CombatEvents.RaiseValidHit(CreateHit(enemy.gameObject));
|
||||
}
|
||||
|
||||
private CombatHitResult CreateHit(
|
||||
GameObject target,
|
||||
bool isDash = false,
|
||||
bool isArtifactHit = false,
|
||||
string sourceId = null)
|
||||
{
|
||||
return new CombatHitResult(
|
||||
playerStats.gameObject,
|
||||
target,
|
||||
isDash,
|
||||
HitSide.Front,
|
||||
1f,
|
||||
Vector2.right,
|
||||
0f,
|
||||
target.transform.position,
|
||||
sourceId: sourceId,
|
||||
isArtifactHit: isArtifactHit);
|
||||
}
|
||||
|
||||
private void SetRunTime(float value)
|
||||
{
|
||||
FieldInfo elapsedTime = typeof(RunManager).GetField(
|
||||
"<ElapsedTime>k__BackingField",
|
||||
NonPublicInstance);
|
||||
Assert.That(elapsedTime, Is.Not.Null);
|
||||
elapsedTime.SetValue(runManager, value);
|
||||
}
|
||||
|
||||
private static void SetEnemyState(EnemyController enemy, EnemyState state)
|
||||
{
|
||||
FieldInfo stateBackingField = typeof(EnemyController).GetField(
|
||||
"<State>k__BackingField",
|
||||
NonPublicInstance);
|
||||
Assert.That(stateBackingField, Is.Not.Null);
|
||||
stateBackingField.SetValue(enemy, state);
|
||||
}
|
||||
|
||||
private static EnemyController FindOrdinarySceneTarget()
|
||||
{
|
||||
foreach (EnemyController enemy in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
if (enemy != null
|
||||
&& !enemy.IsDead
|
||||
&& !enemy.IsEventEnemy
|
||||
&& !enemy.IsSummoned)
|
||||
{
|
||||
return enemy;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d8b2e1c7a4f49e2a6b3c8d1e0f7a965
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ca0cb5b0ddf78c4088ede08d500da82
|
||||
@@ -0,0 +1,455 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class RangeAuditPlayModeTests
|
||||
{
|
||||
private static readonly BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private static readonly EnemyKind[] HorizontalRootKinds =
|
||||
{
|
||||
EnemyKind.Skeleton,
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
EnemyKind.GreatswordSkeleton,
|
||||
EnemyKind.Werewolf,
|
||||
EnemyKind.Werebear,
|
||||
EnemyKind.NecroGolem,
|
||||
EnemyKind.Necromancer,
|
||||
};
|
||||
|
||||
private bool previousGrantCatalogForTests;
|
||||
private bool previousProductionModeForTests;
|
||||
private float previousTimeScale;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
previousGrantCatalogForTests =
|
||||
ArtifactRewardController.GrantCatalogForTests;
|
||||
previousProductionModeForTests =
|
||||
RunManager.ForceProductionModeForTests;
|
||||
previousTimeScale = Time.timeScale;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests =
|
||||
previousGrantCatalogForTests;
|
||||
RunManager.ForceProductionModeForTests =
|
||||
previousProductionModeForTests;
|
||||
Time.timeScale = previousTimeScale;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator NecroGolemAttack03_UsesActualPlayerBodyAndActiveEnd()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
PlayerHealth health = player?.GetComponent<PlayerHealth>();
|
||||
Rigidbody2D playerBody = player?.GetComponent<Rigidbody2D>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
Assert.That(health, Is.Not.Null);
|
||||
Assert.That(playerBody, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableSceneEnemies();
|
||||
player.enabled = false;
|
||||
player.GetComponent<BumpCombatResolver>().enabled = false;
|
||||
|
||||
EnemyController golem = SpawnGolem(director, Vector2.zero);
|
||||
yield return null;
|
||||
ConfigureGolemAttack03(golem, director);
|
||||
golem.enabled = false;
|
||||
EnemyAttack attack = golem.GetComponent<EnemyAttack>();
|
||||
Rigidbody2D golemBody = golem.GetComponent<Rigidbody2D>();
|
||||
AssertGolemAttack03Contract(golem);
|
||||
|
||||
Vector2 root = golemBody.position;
|
||||
Vector2 rightOutside = root + new Vector2(1.8f, 1.9f);
|
||||
Vector2 rightInside = root + new Vector2(1.9f, 0.4f);
|
||||
float activeDuration = golem.ActiveDuration;
|
||||
|
||||
PlacePlayer(playerBody, rightOutside);
|
||||
float healthBefore = health.CurrentHealth;
|
||||
BeginActiveAttack(
|
||||
golem,
|
||||
attack,
|
||||
Vector2.right,
|
||||
playerBody.position);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
attack.TickActive(activeDuration * 0.5f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
PlacePlayer(playerBody, rightInside);
|
||||
attack.TickActive(activeDuration);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthBefore),
|
||||
"A contact first observed at the exact active end must not hit.");
|
||||
attack.TickActive(activeDuration + 0.01f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
attack.EndAttack();
|
||||
SetState(golem, EnemyState.Recovery, golem.RecoveryDuration);
|
||||
attack.TickActive(activeDuration + 0.1f);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(healthBefore));
|
||||
|
||||
PlacePlayer(playerBody, rightInside);
|
||||
BeginActiveAttack(
|
||||
golem,
|
||||
attack,
|
||||
Vector2.right,
|
||||
playerBody.position);
|
||||
float healthAfterRightHit = health.CurrentHealth;
|
||||
Assert.That(
|
||||
healthAfterRightHit,
|
||||
Is.LessThan(healthBefore),
|
||||
"Attack03 must damage the actual player body inside the box.");
|
||||
attack.TickActive(activeDuration * 0.8f);
|
||||
attack.TickActive(activeDuration);
|
||||
attack.TickActive(activeDuration + 0.01f);
|
||||
attack.EndAttack();
|
||||
SetState(golem, EnemyState.Recovery, golem.RecoveryDuration);
|
||||
attack.TickActive(activeDuration + 0.1f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(healthAfterRightHit).Within(0.0001f),
|
||||
"Attack03 must damage once through active end and recovery.");
|
||||
|
||||
yield return new WaitForSecondsRealtime(
|
||||
health.InvulnerabilityDuration + 0.05f);
|
||||
health.Heal(health.MaxHealth);
|
||||
Vector2 leftOutside = root + new Vector2(-1.8f, 1.9f);
|
||||
Vector2 leftInside = root + new Vector2(-1.9f, 0.4f);
|
||||
PlacePlayer(playerBody, leftOutside);
|
||||
float leftBefore = health.CurrentHealth;
|
||||
BeginActiveAttack(
|
||||
golem,
|
||||
attack,
|
||||
Vector2.left,
|
||||
playerBody.position);
|
||||
Assert.That(health.CurrentHealth, Is.EqualTo(leftBefore));
|
||||
PlacePlayer(playerBody, leftInside);
|
||||
attack.TickActive(activeDuration * 0.8f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.LessThan(leftBefore),
|
||||
"The mirrored left Attack03 box must hit the actual body.");
|
||||
float leftAfterHit = health.CurrentHealth;
|
||||
attack.TickActive(activeDuration);
|
||||
attack.TickActive(activeDuration + 0.01f);
|
||||
attack.EndAttack();
|
||||
SetState(golem, EnemyState.Recovery, golem.RecoveryDuration);
|
||||
attack.TickActive(activeDuration + 0.1f);
|
||||
Assert.That(
|
||||
health.CurrentHealth,
|
||||
Is.EqualTo(leftAfterHit).Within(0.0001f));
|
||||
|
||||
Object.Destroy(golem.gameObject);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator HorizontalRootKinds_LockDiagonalRequestedDirection()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableSceneEnemies();
|
||||
|
||||
for (int i = 0; i < HorizontalRootKinds.Length; i++)
|
||||
{
|
||||
EnemyKind kind = HorizontalRootKinds[i];
|
||||
EnemyController enemy = Object.Instantiate(
|
||||
FindCatalogPrefab(director, kind),
|
||||
new Vector2(20f + i * 2f, 20f),
|
||||
Quaternion.identity);
|
||||
yield return null;
|
||||
enemy.enabled = false;
|
||||
EnemyAttack attack = enemy.GetComponent<EnemyAttack>();
|
||||
Assert.That(attack, Is.Not.Null, kind.ToString());
|
||||
Assert.That(
|
||||
enemy.CurrentAttackPattern.DirectionMode,
|
||||
Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot),
|
||||
kind.ToString());
|
||||
|
||||
attack.BeginWarning(
|
||||
new Vector2(1f, 1f).normalized,
|
||||
enemy.transform.position + Vector3.one,
|
||||
enemy.WarningDuration);
|
||||
Assert.That(
|
||||
attack.LockedDirection,
|
||||
Is.EqualTo(Vector2.right),
|
||||
kind + " must resolve positive diagonal input horizontally.");
|
||||
attack.EndAttack(true);
|
||||
|
||||
attack.BeginWarning(
|
||||
new Vector2(-1f, 1f).normalized,
|
||||
enemy.transform.position + Vector3.one,
|
||||
enemy.WarningDuration);
|
||||
Assert.That(
|
||||
attack.LockedDirection,
|
||||
Is.EqualTo(Vector2.left),
|
||||
kind + " must preserve the mirrored horizontal side.");
|
||||
attack.EndAttack(true);
|
||||
Object.Destroy(enemy.gameObject);
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EnemyGroundAnchors_StayOnSharedPlayerBoundaryAfterSpawnWarningAndKnockback()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(director, Is.Not.Null);
|
||||
Assert.That(player, Is.Not.Null);
|
||||
director.enabled = false;
|
||||
DisableSceneEnemies();
|
||||
|
||||
ArenaBounds bounds = Object.FindAnyObjectByType<ArenaBounds>();
|
||||
EnemyController normalPrefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.Skeleton);
|
||||
EnemyController eventPrefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.GreatswordSkeleton);
|
||||
Assert.That(bounds, Is.Not.Null);
|
||||
Assert.That(normalPrefab, Is.Not.Null);
|
||||
Assert.That(eventPrefab, Is.Not.Null);
|
||||
|
||||
Vector2 halfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2[] corners =
|
||||
{
|
||||
new(halfExtents.x, halfExtents.y),
|
||||
new(-halfExtents.x, -halfExtents.y),
|
||||
};
|
||||
for (int i = 0; i < corners.Length; i++)
|
||||
{
|
||||
EnemyController enemy = Object.Instantiate(
|
||||
normalPrefab,
|
||||
corners[i],
|
||||
Quaternion.identity);
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, enemy, "normal spawn");
|
||||
|
||||
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
|
||||
body.position = corners[i];
|
||||
Physics2D.SyncTransforms();
|
||||
SetState(enemy, EnemyState.Warning, 5f);
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, enemy, "warning correction");
|
||||
|
||||
body.position = corners[i];
|
||||
Physics2D.SyncTransforms();
|
||||
bool damaged = enemy.TryTakeDamage(
|
||||
1f,
|
||||
corners[i].normalized,
|
||||
2f);
|
||||
Assert.That(damaged, Is.True, "a valid nonlethal knockback hit must be accepted");
|
||||
yield return new WaitForFixedUpdate();
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, enemy, "knockback correction");
|
||||
Object.Destroy(enemy.gameObject);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
EnemyController eventEnemy = Object.Instantiate(
|
||||
eventPrefab,
|
||||
corners[0],
|
||||
Quaternion.identity);
|
||||
eventEnemy.ConfigureRunEventEnemy(
|
||||
RunTimedEvent.MidBoss,
|
||||
director.GetEventEnemyTuning(RunTimedEvent.MidBoss));
|
||||
yield return null;
|
||||
AssertEnemyBodyInside(bounds, eventEnemy, "scaled event spawn");
|
||||
Object.Destroy(eventEnemy.gameObject);
|
||||
}
|
||||
|
||||
private static void AssertEnemyBodyInside(
|
||||
ArenaBounds bounds,
|
||||
EnemyController enemy,
|
||||
string checkpoint)
|
||||
{
|
||||
Collider2D bodyCollider = enemy.GetComponent<Collider2D>();
|
||||
Assert.That(bodyCollider, Is.Not.Null, checkpoint);
|
||||
Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2 groundAnchor = enemy.GroundAnchorPosition;
|
||||
Assert.That(
|
||||
groundAnchor.x,
|
||||
Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.001f),
|
||||
checkpoint);
|
||||
Assert.That(
|
||||
groundAnchor.x,
|
||||
Is.LessThanOrEqualTo(safeHalfExtents.x + 0.001f),
|
||||
checkpoint);
|
||||
Assert.That(
|
||||
groundAnchor.y,
|
||||
Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.001f),
|
||||
checkpoint);
|
||||
Assert.That(
|
||||
groundAnchor.y,
|
||||
Is.LessThanOrEqualTo(safeHalfExtents.y + 0.001f),
|
||||
checkpoint);
|
||||
}
|
||||
|
||||
private static void AssertGolemAttack03Contract(EnemyController golem)
|
||||
{
|
||||
EnemyAttackPattern pattern = golem.CurrentAttackPattern;
|
||||
Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box));
|
||||
Assert.That(pattern.AttackLength, Is.EqualTo(1.05f).Within(0.0001f));
|
||||
Assert.That(pattern.AttackWidth, Is.EqualTo(0.55f).Within(0.0001f));
|
||||
Assert.That(
|
||||
pattern.AttackOriginOffset,
|
||||
Is.EqualTo(new Vector2(0.05f, -0.05f)));
|
||||
Assert.That(
|
||||
pattern.AnimationLeadTime,
|
||||
Is.EqualTo(0.625f).Within(0.0001f));
|
||||
Assert.That(golem.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
Assert.That(
|
||||
EnemyAttack.GetRootScale(golem.transform),
|
||||
Is.EqualTo(2f).Within(0.001f));
|
||||
Assert.That(golem.AttackLength, Is.EqualTo(2.1f).Within(0.001f));
|
||||
Assert.That(golem.AttackWidth, Is.EqualTo(1.1f).Within(0.001f));
|
||||
}
|
||||
|
||||
private static void BeginActiveAttack(
|
||||
EnemyController enemy,
|
||||
EnemyAttack attack,
|
||||
Vector2 direction,
|
||||
Vector2 target)
|
||||
{
|
||||
attack.BeginWarning(direction, target, enemy.WarningDuration);
|
||||
SetState(enemy, EnemyState.Warning, enemy.WarningDuration);
|
||||
attack.Activate();
|
||||
SetState(enemy, EnemyState.Active, enemy.ActiveDuration);
|
||||
}
|
||||
|
||||
private static EnemyController SpawnGolem(
|
||||
SpawnDirector director,
|
||||
Vector2 position)
|
||||
{
|
||||
EnemyController prefab = FindCatalogPrefab(
|
||||
director,
|
||||
EnemyKind.NecroGolem);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
return Object.Instantiate(prefab, position, Quaternion.identity);
|
||||
}
|
||||
|
||||
private static void ConfigureGolemAttack03(
|
||||
EnemyController golem,
|
||||
SpawnDirector director)
|
||||
{
|
||||
golem.ConfigureRunEventEnemy(
|
||||
RunTimedEvent.MidBoss,
|
||||
director.GetEventEnemyTuning(RunTimedEvent.MidBoss));
|
||||
SetPrivateField(golem, "attackPatternIndex", 2);
|
||||
}
|
||||
|
||||
private static void PlacePlayer(
|
||||
Rigidbody2D playerBody,
|
||||
Vector2 position)
|
||||
{
|
||||
playerBody.linearVelocity = Vector2.zero;
|
||||
playerBody.position = position;
|
||||
Physics2D.SyncTransforms();
|
||||
}
|
||||
|
||||
private static void SetState(
|
||||
EnemyController enemy,
|
||||
EnemyState state,
|
||||
float duration)
|
||||
{
|
||||
typeof(EnemyController)
|
||||
.GetMethod("EnterState", NonPublicInstance)
|
||||
?.Invoke(enemy, new object[] { state, duration });
|
||||
}
|
||||
|
||||
private static void SetPrivateField<T>(
|
||||
object target,
|
||||
string fieldName,
|
||||
T value)
|
||||
{
|
||||
target.GetType()
|
||||
.GetField(fieldName, NonPublicInstance)
|
||||
?.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static void DisableSceneEnemies()
|
||||
{
|
||||
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None);
|
||||
foreach (EnemyController enemy in enemies)
|
||||
{
|
||||
enemy.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static EnemyController FindCatalogPrefab(
|
||||
SpawnDirector director,
|
||||
EnemyKind kind)
|
||||
{
|
||||
string[] fieldNames =
|
||||
{
|
||||
"enemyPrefabs",
|
||||
"elitePrefabs",
|
||||
"midBossPrefabs",
|
||||
"finalBossPrefabs",
|
||||
};
|
||||
foreach (string fieldName in fieldNames)
|
||||
{
|
||||
FieldInfo field = typeof(SpawnDirector).GetField(
|
||||
fieldName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
EnemyController[] prefabs =
|
||||
field?.GetValue(director) as EnemyController[];
|
||||
if (prefabs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (EnemyController prefab in prefabs)
|
||||
{
|
||||
if (prefab != null
|
||||
&& prefab.Definition != null
|
||||
&& prefab.Definition.Kind == kind)
|
||||
{
|
||||
return prefab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
Assert.That(Time.timeScale, Is.EqualTo(1f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d3c7f2e6a1b4c58a0e9d7f6b2c5a813
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class RunMissionTrackerPlayModeTests
|
||||
{
|
||||
private const BindingFlags NonPublicInstance =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
private bool previousGrantCatalogForTests;
|
||||
private bool previousProductionModeForTests;
|
||||
private float previousTimeScale;
|
||||
private string isolatedProfilePath;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
|
||||
previousProductionModeForTests = RunManager.ForceProductionModeForTests;
|
||||
previousTimeScale = Time.timeScale;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
isolatedProfilePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"BumpCombatMissionTests",
|
||||
System.Guid.NewGuid().ToString("N") + ".json");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests;
|
||||
RunManager.ForceProductionModeForTests = previousProductionModeForTests;
|
||||
Time.timeScale = previousTimeScale;
|
||||
if (!string.IsNullOrEmpty(isolatedProfilePath))
|
||||
{
|
||||
if (File.Exists(isolatedProfilePath))
|
||||
{
|
||||
File.Delete(isolatedProfilePath);
|
||||
}
|
||||
|
||||
string temporaryPath = isolatedProfilePath + ".tmp";
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
|
||||
string directory = Path.GetDirectoryName(isolatedProfilePath);
|
||||
if (Directory.Exists(directory)
|
||||
&& Directory.GetFiles(directory).Length == 0)
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ConfirmedCombatEvents_CountOnceBySpawnAndActivation()
|
||||
{
|
||||
yield return LoadCombatScene();
|
||||
RunMissionTracker tracker = FindAny<RunMissionTracker>();
|
||||
RunManager runManager = RunManager.Instance;
|
||||
ExperienceSystem experience = FindAny<ExperienceSystem>();
|
||||
PlayerHealth health = FindAny<PlayerHealth>();
|
||||
SpawnDirector director = FindAny<SpawnDirector>();
|
||||
Assert.That(tracker, Is.Not.Null);
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
Assert.That(experience, Is.Not.Null);
|
||||
Assert.That(health, Is.Not.Null);
|
||||
Assert.That(director, Is.Not.Null);
|
||||
tracker.UseProfileStoreForTests(new UserProfileStore(isolatedProfilePath));
|
||||
director.enabled = false;
|
||||
foreach (EnemyController existing in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
existing.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
ActiveArtifactController artifacts = health.GetComponent<ActiveArtifactController>();
|
||||
Assert.That(artifacts, Is.Not.Null);
|
||||
for (int i = 0; i < artifacts.OwnedArtifactCount
|
||||
&& artifacts.CurrentArtifact?.Effect != ActiveArtifactEffect.Pulse; i++)
|
||||
{
|
||||
artifacts.SelectNext();
|
||||
}
|
||||
Assert.That(artifacts.CurrentArtifact?.Effect, Is.EqualTo(ActiveArtifactEffect.Pulse),
|
||||
"The development catalog should expose the real Pulse activation for integration coverage.");
|
||||
|
||||
EnemyController artifactTarget = SpawnEnemy(
|
||||
director,
|
||||
health.transform.position + Vector3.right * 0.75f,
|
||||
disableImmediately: false);
|
||||
yield return null;
|
||||
artifactTarget.enabled = false;
|
||||
Physics2D.SyncTransforms();
|
||||
Assert.That(artifacts.TryUseCurrent(charged: false), Is.True,
|
||||
"The real artifact activation should execute with available gauge.");
|
||||
float artifactDeadline = Time.realtimeSinceStartup + 1f;
|
||||
while (tracker.GetProgress(3).Progress == 0
|
||||
&& Time.realtimeSinceStartup < artifactDeadline)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(1),
|
||||
"An actual successful Pulse contact must reach the tracker event hook.");
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
|
||||
EnemyController wrongColorShield = SpawnEnemy(
|
||||
director,
|
||||
health.transform.position + Vector3.right * 0.8f,
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
disableImmediately: false);
|
||||
yield return null;
|
||||
wrongColorShield.enabled = false;
|
||||
ConfigureShieldForArtifactTest(wrongColorShield, ArtifactColor.Blue);
|
||||
Assert.That(wrongColorShield.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
|
||||
Assert.That(wrongColorShield.ShieldHitsRemaining, Is.EqualTo(2));
|
||||
Assert.That(tracker.GetProgress(8).Progress, Is.Zero);
|
||||
Physics2D.SyncTransforms();
|
||||
Assert.That(artifacts.TryUseCurrent(charged: false), Is.True);
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(1),
|
||||
"A wrong-color shield contact is not an effective artifact hit.");
|
||||
Assert.That(tracker.GetProgress(8).Progress, Is.Zero,
|
||||
"A wrong-color shield contact does not decrement its required hits.");
|
||||
Assert.That(wrongColorShield.ShieldHitsRemaining, Is.EqualTo(2),
|
||||
"Wrong-color contacts leave the shield's required-hit counter unchanged.");
|
||||
|
||||
EnemyController matchingColorShield = SpawnEnemy(
|
||||
director,
|
||||
health.transform.position + Vector3.right * 0.8f,
|
||||
EnemyKind.ArmoredSkeleton,
|
||||
disableImmediately: false);
|
||||
yield return null;
|
||||
matchingColorShield.enabled = false;
|
||||
ConfigureShieldForArtifactTest(matchingColorShield, ArtifactColor.Red);
|
||||
Physics2D.SyncTransforms();
|
||||
Assert.That(artifacts.TryUseCurrent(charged: false), Is.True);
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(2),
|
||||
"A matching shield pip is an effective artifact activation.");
|
||||
Assert.That(tracker.GetProgress(8).Progress, Is.EqualTo(1),
|
||||
"Only its actual matching-color decrement advances the shield mission.");
|
||||
|
||||
EnemyController first = SpawnEnemy(director, Vector2.right * 3f);
|
||||
EnemyController second = SpawnEnemy(director, Vector2.left * 3f);
|
||||
|
||||
CombatEvents.RaiseValidHit(CreateBumpHit(experience.gameObject, first.gameObject,
|
||||
HitSide.Back, 0f));
|
||||
Assert.That(tracker.GetProgress(2).Progress, Is.Zero,
|
||||
"A contact that dealt no damage must not count as a successful bump.");
|
||||
|
||||
CombatHitResult rearHit = CreateBumpHit(
|
||||
experience.gameObject,
|
||||
first.gameObject,
|
||||
HitSide.Back,
|
||||
1f);
|
||||
CombatEvents.RaiseValidHit(rearHit);
|
||||
CombatEvents.RaiseValidHit(rearHit);
|
||||
CombatEvents.RaiseValidHit(CreateBumpHit(
|
||||
experience.gameObject,
|
||||
second.gameObject,
|
||||
HitSide.Back,
|
||||
1f));
|
||||
Assert.That(tracker.GetProgress(2).Progress, Is.EqualTo(3));
|
||||
Assert.That(tracker.GetProgress(4).Progress, Is.EqualTo(2),
|
||||
"Rear progress is once per enemy lifetime, while bump progress records each real hit.");
|
||||
|
||||
EnemyController lethal = SpawnEnemy(
|
||||
director,
|
||||
health.transform.position + Vector3.left * 4f,
|
||||
disableImmediately: false);
|
||||
yield return null;
|
||||
Assert.That(lethal.CurrentHealth, Is.GreaterThan(0f));
|
||||
SetPrivateField<ExperienceOrb>(lethal, "experienceOrbPrefab", null);
|
||||
Assert.That(lethal.TryTakeDamage(
|
||||
lethal.CurrentHealth + 1f,
|
||||
Vector2.right,
|
||||
0.1f), Is.True,
|
||||
"A real lethal EnemyController hit should publish its death event.");
|
||||
Assert.That(tracker.GetProgress(0).Progress, Is.EqualTo(1));
|
||||
CombatEvents.RaiseEnemyDied(lethal.gameObject);
|
||||
Assert.That(tracker.GetProgress(0).Progress, Is.EqualTo(1),
|
||||
"A duplicate callback for that same spawn lifetime must not count twice.");
|
||||
|
||||
experience.AddExperience(1);
|
||||
Assert.That(tracker.GetProgress(1).Progress, Is.Zero,
|
||||
"Free mission XP must not masquerade as an orb pickup.");
|
||||
|
||||
GameObject orbObject = new("Mission test experience orb");
|
||||
orbObject.transform.position = experience.transform.position;
|
||||
ExperienceOrb orb = orbObject.AddComponent<ExperienceOrb>();
|
||||
Rigidbody2D orbBody = orbObject.GetComponent<Rigidbody2D>();
|
||||
orbBody.gravityScale = 0f;
|
||||
CircleCollider2D orbCollider = orbObject.GetComponent<CircleCollider2D>();
|
||||
orbCollider.isTrigger = true;
|
||||
orb.Initialize(1);
|
||||
yield return null;
|
||||
yield return new WaitForFixedUpdate();
|
||||
Assert.That(tracker.GetProgress(1).Progress, Is.EqualTo(1),
|
||||
"A nearby orb should report one actual pickup through its normal physics path.");
|
||||
|
||||
health.GrantInvulnerability(2f);
|
||||
health.TryTakeDamage(0f, Vector2.zero, 0f);
|
||||
health.TryTakeDamage(5f, Vector2.right, 0.1f);
|
||||
Assert.That(tracker.GetProgress(7).Progress, Is.Zero,
|
||||
"Zero damage and ordinary invulnerability do not count as a guard block.");
|
||||
Assert.That(health.TryActivateGuard(), Is.True);
|
||||
Assert.That(health.TryTakeDamage(5f, Vector2.right, 0.1f), Is.False);
|
||||
Assert.That(health.TryTakeDamage(5f, Vector2.right, 0.1f), Is.False);
|
||||
Assert.That(tracker.GetProgress(7).Progress, Is.EqualTo(1),
|
||||
"Multiple blocked hits from one guard activation count once.");
|
||||
|
||||
CombatEvents.RaiseArtifactEffectiveHit(
|
||||
first.gameObject,
|
||||
2001,
|
||||
ActiveArtifactEffect.Pulse,
|
||||
isCharged: true,
|
||||
matchedShieldRequirement: false);
|
||||
CombatEvents.RaiseArtifactEffectiveHit(
|
||||
first.gameObject,
|
||||
2001,
|
||||
ActiveArtifactEffect.Pulse,
|
||||
isCharged: true,
|
||||
matchedShieldRequirement: false);
|
||||
Assert.That(tracker.GetProgress(3).Progress, Is.EqualTo(3));
|
||||
Assert.That(tracker.GetProgress(9).Progress, Is.EqualTo(1),
|
||||
"One charged activation counts once even if it affects a target repeatedly.");
|
||||
Assert.That(tracker.GetProgress(8).Progress, Is.EqualTo(1),
|
||||
"Only the earlier actual matching-color shield decrement has counted.");
|
||||
|
||||
EnemyController[] crowdTargets = new EnemyController[5];
|
||||
for (int i = 0; i < crowdTargets.Length; i++)
|
||||
{
|
||||
crowdTargets[i] = i == 0
|
||||
? first
|
||||
: SpawnEnemy(director, new Vector2(i + 5f, 0f));
|
||||
CombatEvents.RaiseArtifactEffectiveHit(
|
||||
crowdTargets[i].gameObject,
|
||||
3001,
|
||||
ActiveArtifactEffect.Cyclone,
|
||||
isCharged: false,
|
||||
matchedShieldRequirement: i == 0);
|
||||
}
|
||||
|
||||
Assert.That(tracker.GetProgress(5).Progress, Is.EqualTo(5));
|
||||
Assert.That(tracker.GetProgress(5).Completed, Is.True);
|
||||
Assert.That(tracker.GetProgress(8).Progress, Is.EqualTo(2),
|
||||
"Only a counted matching-color shield decrement advances the shield mission.");
|
||||
Assert.That(tracker.GetLifetimeMissionCompletions(), Is.Zero,
|
||||
"A development/test run must not write permanent mission records.");
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator RampageWindowIncludesExactThreeSecondBoundary_AndGameOverStopsEvents()
|
||||
{
|
||||
yield return LoadCombatScene();
|
||||
RunMissionTracker tracker = FindAny<RunMissionTracker>();
|
||||
RunManager runManager = RunManager.Instance;
|
||||
ExperienceSystem experience = FindAny<ExperienceSystem>();
|
||||
SpawnDirector director = FindAny<SpawnDirector>();
|
||||
Assert.That(tracker, Is.Not.Null);
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
Assert.That(experience, Is.Not.Null);
|
||||
Assert.That(director, Is.Not.Null);
|
||||
tracker.UseProfileStoreForTests(new UserProfileStore(isolatedProfilePath));
|
||||
director.enabled = false;
|
||||
foreach (EnemyController existing in Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsSortMode.None))
|
||||
{
|
||||
existing.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
SetAutoProperty(runManager, "ElapsedTime", 0f);
|
||||
EnemyController first = SpawnEnemy(director, new Vector2(2f, 0f));
|
||||
CombatEvents.RaiseEnemyDied(first.gameObject);
|
||||
CombatEvents.RaiseEnemyDied(first.gameObject);
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(1),
|
||||
"A duplicate death callback from one spawn lifetime counts once.");
|
||||
|
||||
EnemyController summoned = SpawnEnemy(director, new Vector2(3f, 0f));
|
||||
summoned.ConfigureSummonedEnemy(
|
||||
null,
|
||||
RunEventEnemyTuning.Create(
|
||||
EnemyKind.Skeleton,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
Color.white,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
1f,
|
||||
1,
|
||||
1f,
|
||||
true),
|
||||
phaseProtected: false);
|
||||
CombatEvents.RaiseEnemyDied(summoned.gameObject);
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(1),
|
||||
"Summons do not count toward the non-summoned kill streak.");
|
||||
Assert.That(tracker.GetProgress(0).Progress, Is.EqualTo(1),
|
||||
"Summons also do not count toward the first-sweep mission.");
|
||||
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
EnemyController enemy = SpawnEnemy(director, new Vector2(i + 4f, 0f));
|
||||
CombatEvents.RaiseEnemyDied(enemy.gameObject);
|
||||
}
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(9));
|
||||
|
||||
SetAutoProperty(runManager, "ElapsedTime", 4f);
|
||||
EnemyController tenth = SpawnEnemy(director, Vector2.left * 4f);
|
||||
CombatEvents.RaiseEnemyDied(tenth.gameObject);
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(1),
|
||||
"Kills older than three combat seconds leave the sliding window.");
|
||||
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
SetAutoProperty(runManager, "ElapsedTime", 4.1f + i * 0.1f);
|
||||
EnemyController enemy = SpawnEnemy(director, new Vector2(i + 6f, 0f));
|
||||
CombatEvents.RaiseEnemyDied(enemy.gameObject);
|
||||
}
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(9));
|
||||
|
||||
SetAutoProperty(runManager, "ElapsedTime", 7f);
|
||||
EnemyController final = SpawnEnemy(director, Vector2.left * 6f);
|
||||
CombatEvents.RaiseEnemyDied(final.gameObject);
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(10));
|
||||
Assert.That(tracker.GetProgress(10).Completed, Is.True,
|
||||
"A kill exactly three combat seconds after the oldest valid kill completes the inclusive window.");
|
||||
|
||||
runManager.EndRun();
|
||||
int bumpBeforeTerminalEvents = tracker.GetProgress(2).Progress;
|
||||
int orbBeforeTerminalEvents = tracker.GetProgress(1).Progress;
|
||||
CombatEvents.RaiseValidHit(CreateBumpHit(
|
||||
experience.gameObject,
|
||||
tenth.gameObject,
|
||||
HitSide.Front,
|
||||
1f));
|
||||
CombatEvents.RaiseArtifactEffectiveHit(
|
||||
tenth.gameObject,
|
||||
4001,
|
||||
ActiveArtifactEffect.Pulse,
|
||||
isCharged: false,
|
||||
matchedShieldRequirement: false);
|
||||
experience.CollectExperienceOrb(1);
|
||||
|
||||
Assert.That(tracker.GetProgress(2).Progress, Is.EqualTo(bumpBeforeTerminalEvents));
|
||||
Assert.That(tracker.GetProgress(1).Progress, Is.EqualTo(orbBeforeTerminalEvents));
|
||||
Assert.That(tracker.GetProgress(3).Progress, Is.Zero);
|
||||
Assert.That(tracker.GetProgress(10).Progress, Is.EqualTo(10),
|
||||
"Completed rampage progress must not decay after its success or after game over.");
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ProductionRun_PersistsMissionCompletionAndHonorsUnlockGates()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
yield return LoadCombatScene();
|
||||
RunMissionTracker tracker = FindAny<RunMissionTracker>();
|
||||
RunManager runManager = RunManager.Instance;
|
||||
ExperienceSystem experience = FindAny<ExperienceSystem>();
|
||||
Assert.That(tracker, Is.Not.Null);
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
Assert.That(experience, Is.Not.Null);
|
||||
Assert.That(runManager.IsTitleScreen, Is.True);
|
||||
tracker.UseProfileStoreForTests(new UserProfileStore(isolatedProfilePath));
|
||||
|
||||
Assert.That(runManager.BeginRun(RunMode.Production), Is.True);
|
||||
Assert.That(tracker.GetProgress(7).Locked, Is.True,
|
||||
"Guard missions stay locked until the run unlocks guard.");
|
||||
Assert.That(tracker.GetProgress(9).Locked, Is.True,
|
||||
"Charged missions stay locked until the run unlocks charge.");
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
experience.CollectExperienceOrb(0);
|
||||
}
|
||||
|
||||
Assert.That(tracker.GetProgress(1).Completed, Is.True);
|
||||
Assert.That(tracker.GetLifetimeMissionCompletions(), Is.EqualTo(1));
|
||||
Assert.That(File.Exists(isolatedProfilePath), Is.True);
|
||||
string savedProfile = File.ReadAllText(isolatedProfilePath);
|
||||
Assert.That(savedProfile, Does.Contain("mission.growth_footing"));
|
||||
|
||||
runManager.UnlockGuard();
|
||||
runManager.UnlockArtifactCharge();
|
||||
Assert.That(tracker.GetProgress(7).Locked, Is.False);
|
||||
Assert.That(tracker.GetProgress(9).Locked, Is.False);
|
||||
Assert.That(tracker.ProfileStore.Profile.achievements, Is.Empty,
|
||||
"No uncommissioned achievements are synthesized into the profile.");
|
||||
Assert.That(tracker.ProfileStore.Profile.unlockedSkinIds, Is.Empty);
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatScene()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static T FindAny<T>() where T : Object
|
||||
{
|
||||
return Object.FindAnyObjectByType<T>();
|
||||
}
|
||||
|
||||
private static EnemyController SpawnEnemy(
|
||||
SpawnDirector director,
|
||||
Vector2 position,
|
||||
EnemyKind kind = EnemyKind.Skeleton,
|
||||
bool disableImmediately = true)
|
||||
{
|
||||
EnemyController prefab = FindCatalogPrefab(director, kind);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
EnemyController enemy = Object.Instantiate(
|
||||
prefab,
|
||||
position,
|
||||
Quaternion.identity);
|
||||
if (disableImmediately)
|
||||
{
|
||||
enemy.enabled = false;
|
||||
}
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private static EnemyController FindCatalogPrefab(
|
||||
SpawnDirector director,
|
||||
EnemyKind kind)
|
||||
{
|
||||
string[] fieldNames =
|
||||
{
|
||||
"enemyPrefabs",
|
||||
"elitePrefabs",
|
||||
"midBossPrefabs",
|
||||
"finalBossPrefabs",
|
||||
};
|
||||
foreach (string fieldName in fieldNames)
|
||||
{
|
||||
FieldInfo field = typeof(SpawnDirector).GetField(
|
||||
fieldName,
|
||||
NonPublicInstance);
|
||||
EnemyController[] prefabs = field?.GetValue(director) as EnemyController[];
|
||||
if (prefabs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (EnemyController prefab in prefabs)
|
||||
{
|
||||
if (prefab != null
|
||||
&& prefab.Definition != null
|
||||
&& prefab.Definition.Kind == kind)
|
||||
{
|
||||
return prefab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CombatHitResult CreateBumpHit(
|
||||
GameObject attacker,
|
||||
GameObject target,
|
||||
HitSide side,
|
||||
float damage)
|
||||
{
|
||||
return new CombatHitResult(
|
||||
attacker,
|
||||
target,
|
||||
false,
|
||||
side,
|
||||
damage,
|
||||
Vector2.right,
|
||||
1f,
|
||||
target.transform.position);
|
||||
}
|
||||
|
||||
private static void ConfigureShieldForArtifactTest(
|
||||
EnemyController enemy,
|
||||
ArtifactColor shieldColor)
|
||||
{
|
||||
SetPrivateField(enemy, "groggyTierConfigured", true);
|
||||
SetPrivateField(enemy, "groggyTier", RunTimedEvent.Elite);
|
||||
SetPrivateField(enemy, "requiredGroggyDistinctArtifacts", 1);
|
||||
SetPrivateField(enemy, "requiredGroggyContactsPerArtifact", 2);
|
||||
SetPrivateField(enemy, "groggyContactCount", 0);
|
||||
SetPrivateField(enemy, "isGroggy", false);
|
||||
SetPrivateField(enemy, "shieldColor", shieldColor);
|
||||
}
|
||||
|
||||
private static void SetPrivateField<T>(object target, string fieldName, T value)
|
||||
{
|
||||
target.GetType()
|
||||
.GetField(fieldName, NonPublicInstance)
|
||||
?.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static void SetAutoProperty<T>(object target, string propertyName, T value)
|
||||
{
|
||||
target.GetType()
|
||||
.GetField(
|
||||
$"<{propertyName}>k__BackingField",
|
||||
NonPublicInstance)
|
||||
?.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec14e1fb9ff64ceeb00b7ed47c075baa
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.UI;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class RunTutorialPlayModeTests
|
||||
{
|
||||
private Keyboard virtualKeyboard;
|
||||
private bool virtualKeyboardInputActive;
|
||||
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
|
||||
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
|
||||
private bool previousRunInBackground;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
EndVirtualKeyboardInput();
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DevelopmentRun_ShowsArtifactGuardAndEnhancementGuidesAfterStartupChoices()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
PlayerHealth health = Object.FindAnyObjectByType<PlayerHealth>();
|
||||
|
||||
Assert.That(runManager, Is.Not.Null);
|
||||
Assert.That(rewards, Is.Not.Null);
|
||||
Assert.That(tutorials, Is.Not.Null);
|
||||
Assert.That(health, Is.Not.Null);
|
||||
Assert.That(health.GuardDuration,
|
||||
Is.EqualTo(PlayerHealth.DefaultGuardDuration));
|
||||
Assert.That(health.GuardDuration, Is.EqualTo(1f));
|
||||
Assert.That(health.GuardCooldownDuration,
|
||||
Is.EqualTo(PlayerHealth.DefaultGuardCooldown));
|
||||
Assert.That(health.GuardSuccessCooldownRechargePercent,
|
||||
Is.EqualTo(PlayerHealth.DefaultGuardSuccessCooldownRechargePercent));
|
||||
Assert.That(runManager.BeginRun(RunMode.Development), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(tutorials.DebugIsVisible, Is.True);
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Artifact);
|
||||
AssertArtifactTutorialPresentation(tutorials);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.EqualTo(2));
|
||||
Assert.That(tutorials.DebugHasRaycastBlocker, Is.True);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.True);
|
||||
Assert.That(Time.timeScale, Is.Zero);
|
||||
Assert.That(tutorials.DebugBodyText, Does.Not.Contain("예시:"));
|
||||
|
||||
SetPrivateField(health, "guardDuration", 1.25f);
|
||||
SetPrivateField(health, "guardCooldownDuration", 8.5f);
|
||||
SetPrivateField(health, "guardSuccessCooldownRechargePercent", 42.5f);
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Guard);
|
||||
Assert.That(tutorials.DebugBodyText, Does.Contain("1.25초"));
|
||||
Assert.That(tutorials.DebugBodyText, Does.Contain("8.5초입니다."));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("가드 성공 시 가드 게이지를 42.5% 충전합니다."));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("발동당 1회 적용되며, 남은 대기시간이 줄어듭니다."));
|
||||
Assert.That(tutorials.DebugBodyText, Does.Not.Contain("0.5초"));
|
||||
Assert.That(tutorials.DebugBodyText, Does.Not.Contain("10초"));
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
AssertTutorial(tutorials,
|
||||
RunTutorialController.TutorialKind.ArtifactEnhancement);
|
||||
Assert.That(tutorials.DebugBodyText, Does.Not.Contain("예시:"));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("A를 길게 눌러 완충한 뒤 놓으면 강화기를 발동합니다."));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("강화기는 일반기와 같은 양의 게이지를 소모합니다."));
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.False);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.True);
|
||||
Assert.That(Time.timeScale, Is.EqualTo(1f));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ArtifactRewardSpace_OpensTutorialWithoutReusingTheSamePress()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
|
||||
Assert.That(runManager.BeginRun(RunMode.Production), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
yield return PressAndRelease(keyboard, Key.Space);
|
||||
|
||||
Assert.That(tutorials.DebugIsVisible, Is.True);
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Artifact);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.True);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TutorialContinue_OpensQueuedRewardWithoutReusingTheSamePress()
|
||||
{
|
||||
yield return LoadProductionRunWithArtifactTutorial();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
ActiveArtifactController artifacts =
|
||||
Object.FindAnyObjectByType<ActiveArtifactController>();
|
||||
|
||||
rewards.DebugQueueRedArtifactReward();
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.False);
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Artifact);
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
yield return PressAndRelease(keyboard, Key.Space);
|
||||
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(rewards.DebugShownCount, Is.EqualTo(2));
|
||||
Assert.That(artifacts.OwnedArtifactCount, Is.EqualTo(1),
|
||||
"The tutorial confirm press must not select the newly opened reward.");
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.True);
|
||||
|
||||
yield return PressAndRelease(keyboard, Key.Space);
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.False);
|
||||
Assert.That(artifacts.OwnedArtifactCount, Is.EqualTo(2));
|
||||
Assert.That(runManager.IsSelectionOpen, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TutorialContinue_OpensQueuedLevelUpWithoutReusingTheSamePress()
|
||||
{
|
||||
yield return LoadProductionRunWithArtifactTutorial();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
LevelUpController levelUp =
|
||||
Object.FindAnyObjectByType<LevelUpController>();
|
||||
ExperienceSystem experience =
|
||||
Object.FindAnyObjectByType<ExperienceSystem>();
|
||||
experience.AddExperience(ExperienceSystem.RequiredExperienceForLevel(1));
|
||||
Assert.That(levelUp.DebugIsSelectionVisible, Is.False);
|
||||
|
||||
Keyboard keyboard = BeginVirtualKeyboardInput();
|
||||
yield return PressAndRelease(keyboard, Key.Space);
|
||||
|
||||
Assert.That(levelUp.DebugIsSelectionVisible, Is.True);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.True);
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
yield return PressAndRelease(keyboard, Key.Space);
|
||||
Assert.That(levelUp.DebugIsSelectionVisible, Is.False);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator UnlockTutorials_QueueBehindSelectionAndPauseAndIgnoreRepeatedUnlocks()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
|
||||
Assert.That(runManager.BeginRun(RunMode.Production), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugIsSelectionVisible, Is.True);
|
||||
|
||||
runManager.UnlockGuard();
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.EqualTo(1));
|
||||
Assert.That(runManager.IsSelectionOpen, Is.True);
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Artifact);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.EqualTo(1));
|
||||
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Guard);
|
||||
runManager.UnlockGuard();
|
||||
Assert.That(tutorials.DebugPendingCount, Is.Zero);
|
||||
Assert.That(tutorials.DebugCurrentTutorial,
|
||||
Is.EqualTo(RunTutorialController.TutorialKind.Guard));
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(runManager.TryOpenPause(), Is.True);
|
||||
|
||||
runManager.UnlockArtifactCharge();
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.EqualTo(1));
|
||||
Assert.That(runManager.IsPaused, Is.True);
|
||||
Assert.That(runManager.ClosePause(), Is.True);
|
||||
AssertTutorial(tutorials,
|
||||
RunTutorialController.TutorialKind.ArtifactEnhancement);
|
||||
Assert.That(tutorials.DebugContinue(), Is.True);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DisableAndGameOver_ReleaseModalAndClearPendingGuides()
|
||||
{
|
||||
yield return LoadProductionRunWithArtifactTutorial();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
runManager.UnlockGuard();
|
||||
|
||||
tutorials.enabled = false;
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.False);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.True);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.EqualTo(2));
|
||||
|
||||
tutorials.enabled = true;
|
||||
AssertTutorial(tutorials, RunTutorialController.TutorialKind.Artifact);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.True);
|
||||
runManager.EndRun();
|
||||
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(tutorials.DebugCurrentTutorial, Is.Null);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.Zero);
|
||||
Assert.That(runManager.IsSelectionOpen, Is.False);
|
||||
Assert.That(runManager.IsGameOver, Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CatalogTestGrant_DoesNotQueueTutorials()
|
||||
{
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
yield return LoadScene();
|
||||
|
||||
RunTutorialController tutorials =
|
||||
Object.FindAnyObjectByType<RunTutorialController>();
|
||||
Assert.That(tutorials, Is.Not.Null);
|
||||
Assert.That(tutorials.DebugPendingCount, Is.Zero);
|
||||
Assert.That(tutorials.DebugIsVisible, Is.False);
|
||||
Assert.That(RunManager.GameplayInputEnabled, Is.True);
|
||||
}
|
||||
|
||||
private IEnumerator LoadProductionRunWithArtifactTutorial()
|
||||
{
|
||||
yield return LoadScene();
|
||||
RunManager runManager = Object.FindAnyObjectByType<RunManager>();
|
||||
ArtifactRewardController rewards =
|
||||
Object.FindAnyObjectByType<ArtifactRewardController>();
|
||||
Assert.That(runManager.BeginRun(RunMode.Production), Is.True);
|
||||
yield return null;
|
||||
Assert.That(rewards.DebugChooseOption(0), Is.True);
|
||||
AssertTutorial(
|
||||
Object.FindAnyObjectByType<RunTutorialController>(),
|
||||
RunTutorialController.TutorialKind.Artifact);
|
||||
}
|
||||
|
||||
private Keyboard BeginVirtualKeyboardInput()
|
||||
{
|
||||
Assert.That(virtualKeyboardInputActive, Is.False);
|
||||
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
|
||||
previousEditorInputBehavior =
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode;
|
||||
previousRunInBackground = Application.runInBackground;
|
||||
InputSystem.settings.backgroundBehavior =
|
||||
InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
InputSettings.EditorInputBehaviorInPlayMode
|
||||
.AllDeviceInputAlwaysGoesToGameView;
|
||||
Application.runInBackground = true;
|
||||
virtualKeyboard = InputSystem.AddDevice<Keyboard>();
|
||||
virtualKeyboard.MakeCurrent();
|
||||
virtualKeyboardInputActive = true;
|
||||
return virtualKeyboard;
|
||||
}
|
||||
|
||||
private void EndVirtualKeyboardInput()
|
||||
{
|
||||
if (!virtualKeyboardInputActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (virtualKeyboard != null && virtualKeyboard.added)
|
||||
{
|
||||
InputSystem.RemoveDevice(virtualKeyboard);
|
||||
}
|
||||
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
|
||||
InputSystem.settings.editorInputBehaviorInPlayMode =
|
||||
previousEditorInputBehavior;
|
||||
Application.runInBackground = previousRunInBackground;
|
||||
virtualKeyboard = null;
|
||||
virtualKeyboardInputActive = false;
|
||||
}
|
||||
|
||||
private static IEnumerator PressAndRelease(Keyboard keyboard, Key key)
|
||||
{
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState(key));
|
||||
yield return null;
|
||||
Assert.That(keyboard[key].isPressed, Is.True);
|
||||
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
||||
yield return null;
|
||||
Assert.That(keyboard[key].isPressed, Is.False);
|
||||
}
|
||||
|
||||
private static void AssertTutorial(
|
||||
RunTutorialController tutorials,
|
||||
RunTutorialController.TutorialKind expected)
|
||||
{
|
||||
Assert.That(tutorials, Is.Not.Null);
|
||||
Assert.That(tutorials.DebugIsVisible, Is.True);
|
||||
Assert.That(tutorials.DebugCurrentTutorial, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
private static void SetPrivateField<T>(
|
||||
object target,
|
||||
string fieldName,
|
||||
T value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(
|
||||
fieldName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(field, Is.Not.Null, $"Missing field: {fieldName}");
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
|
||||
private static void AssertArtifactTutorialPresentation(
|
||||
RunTutorialController tutorials)
|
||||
{
|
||||
TutorialMotionPreview preview = tutorials.DebugMotionPreview;
|
||||
Assert.That(preview, Is.Not.Null);
|
||||
Assert.That(preview.gameObject.activeInHierarchy, Is.True);
|
||||
RectTransform previewRect = preview.transform as RectTransform;
|
||||
Assert.That(previewRect, Is.Not.Null);
|
||||
Assert.That(previewRect.sizeDelta, Is.EqualTo(new Vector2(560f, 350f)));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("1. 아티팩트를 더 얻은 뒤 S로 교체합니다."));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("2. A를 눌렀다 놓으면 일반기를 사용합니다."));
|
||||
Assert.That(tutorials.DebugBodyText,
|
||||
Does.Contain("3. 이동하거나 몸통박치기에 성공하면 공용 게이지가 찹니다."));
|
||||
Assert.That(tutorials.DebugHelpText,
|
||||
Is.EqualTo("왼쪽은 자동 시연입니다 · Enter / Space 계속"));
|
||||
}
|
||||
|
||||
private static IEnumerator LoadScene()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5fc0e5c567674d16a9e0bbcdf56b0c61
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f4d92e15b7d4c03ab9aa7f8b89d02f1
|
||||
@@ -0,0 +1,540 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.IO;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Spawning;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace BumpCombat.PlayModeTests
|
||||
{
|
||||
public sealed class StatusVisualIntegrationTests
|
||||
{
|
||||
private const string IgniteVisualName = "Ignite Status Visual";
|
||||
private const string ShockVisualName = "Shock Status Visual";
|
||||
private const string CycloneGhostName = "Cyclone Move Afterimage";
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CrowdControlSpirals_FollowHeadsRotatePauseAndReplaceGroggyPips()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
var director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
director.enabled = false;
|
||||
foreach (var old in Object.FindObjectsByType<EnemyController>(FindObjectsSortMode.None))
|
||||
old.gameObject.SetActive(false);
|
||||
var player = Object.FindAnyObjectByType<PlayerController>();
|
||||
player.GetComponent<Rigidbody2D>().position = new Vector2(8f, -5f);
|
||||
var prefabs = new List<EnemyController>();
|
||||
foreach (string field in new[] { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs" })
|
||||
prefabs.AddRange((EnemyController[])typeof(SpawnDirector).GetField(field, BindingFlags.NonPublic | BindingFlags.Instance).GetValue(director));
|
||||
var actors = new List<EnemyController>();
|
||||
var roles = new[] { RunTimedEvent.Elite, RunTimedEvent.MidBoss, RunTimedEvent.FinalBoss };
|
||||
var kinds = new[] { EnemyKind.Slime, EnemyKind.Bat,
|
||||
director.GetEventEnemyTuning(roles[0]).PrefabKind,
|
||||
director.GetEventEnemyTuning(roles[1]).PrefabKind,
|
||||
director.GetEventEnemyTuning(roles[2]).PrefabKind };
|
||||
for (int i = 0; i < kinds.Length; i++)
|
||||
{
|
||||
var prefab = prefabs.Find(p => p != null && p.Definition.Kind == kinds[i]);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
actors.Add(Object.Instantiate(prefab, new Vector3((i - 2) * 2.6f, 0f, 0f), Quaternion.identity));
|
||||
if (i >= 2) actors[i].ConfigureRunEventEnemy(roles[i - 2], director.GetEventEnemyTuning(roles[i - 2]));
|
||||
}
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
for (int i = 0; i < actors.Count; i++) actors[i].TryReposition(new Vector2((i - 2) * 2.6f, 0f));
|
||||
Assert.That(actors[0].ApplyStun(3f), Is.True);
|
||||
Assert.That(actors[1].ApplyStun(3f), Is.True);
|
||||
for (int i = 2; i < actors.Count; i++)
|
||||
{
|
||||
for (int contact = 0; contact < 10 && !actors[i].IsGroggy; contact++)
|
||||
actors[i].RegisterArtifactContact(actors[i].ShieldColor, 987654 + contact);
|
||||
Assert.That(actors[i].IsGroggy, Is.True, kinds[i].ToString());
|
||||
}
|
||||
Assert.That(actors[2].IsGroggy, Is.True);
|
||||
yield return null;
|
||||
var marker = actors[0].transform.Find("Stun Runes").GetComponent<SpriteRenderer>();
|
||||
Sprite first = marker.sprite;
|
||||
yield return new WaitForSeconds(.075f);
|
||||
Assert.That(marker.sprite, Is.Not.SameAs(first));
|
||||
actors[0].TryReposition(new Vector2(-5.4f, .1f));
|
||||
yield return null;
|
||||
for (int i = 0; i < actors.Count; i++)
|
||||
{
|
||||
var actor = actors[i];
|
||||
var body = actor.GetComponent<SpriteRenderer>();
|
||||
var cc = actor.transform.Find(i >= 2 ? "Groggy Spiral" : "Stun Runes").GetComponent<SpriteRenderer>();
|
||||
Assert.That(cc.enabled, Is.True);
|
||||
Assert.That(cc.transform.lossyScale.x, Is.EqualTo(.5f * body.transform.lossyScale.x).Within(.0001f));
|
||||
Assert.That(cc.transform.lossyScale.y, Is.EqualTo(.5f * body.transform.lossyScale.y).Within(.0001f));
|
||||
Assert.That(cc.sprite.texture.name, Does.Contain(i >= 2 ? "Groggy" : "Stun"));
|
||||
Assert.That(Vector3.Distance(cc.transform.position, CrowdControlMarkerArt.MarkerWorldPosition(body)), Is.LessThan(.02f));
|
||||
Assert.That(cc.sortingOrder, Is.EqualTo(body.sortingOrder + 100));
|
||||
Assert.That(actor.transform.Find("Stagger Pips"), Is.Null);
|
||||
Assert.That(actor.transform.Find("Groggy Star 0"), Is.Null);
|
||||
}
|
||||
Time.timeScale = 0f;
|
||||
yield return null;
|
||||
Sprite paused = marker.sprite;
|
||||
yield return new WaitForSecondsRealtime(.12f);
|
||||
Assert.That(marker.sprite, Is.SameAs(paused));
|
||||
CaptureCrowdControlReview();
|
||||
Time.timeScale = 1f;
|
||||
actors[0].gameObject.SetActive(false);
|
||||
yield return null;
|
||||
Assert.That(marker == null || !marker.enabled || !marker.gameObject.activeInHierarchy, Is.True);
|
||||
LogAssert.NoUnexpectedReceived();
|
||||
}
|
||||
|
||||
private static void CaptureCrowdControlReview()
|
||||
{
|
||||
var host = new GameObject("CC Review Camera");
|
||||
var camera = host.AddComponent<Camera>();
|
||||
camera.enabled = false;
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = 3.8f;
|
||||
camera.aspect = 1440f / 720f;
|
||||
camera.transform.position = new Vector3(0f, .3f, -10f);
|
||||
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||
camera.backgroundColor = new Color32(31, 34, 40, 255);
|
||||
camera.cullingMask = ~(1 << 5);
|
||||
var renderTarget = new RenderTexture(1440, 720, 24);
|
||||
renderTarget.Create();
|
||||
camera.targetTexture = renderTarget;
|
||||
camera.Render();
|
||||
var previous = RenderTexture.active;
|
||||
RenderTexture.active = renderTarget;
|
||||
var snapshot = new Texture2D(1440, 720, TextureFormat.RGB24, false);
|
||||
snapshot.ReadPixels(new Rect(0, 0, 1440, 720), 0, 0);
|
||||
snapshot.Apply();
|
||||
string folder = Path.GetFullPath(Path.Combine(Application.dataPath, "../../../../output/cc-proportional-guard-v3"));
|
||||
// The optional image is local QA output; CI without this workspace only runs assertions.
|
||||
if (Directory.Exists(folder)) File.WriteAllBytes(Path.Combine(folder, "runtime-review.png"), snapshot.EncodeToPNG());
|
||||
RenderTexture.active = previous;
|
||||
camera.targetTexture = null;
|
||||
renderTarget.Release();
|
||||
Object.Destroy(snapshot);
|
||||
Object.Destroy(renderTarget);
|
||||
Object.Destroy(host);
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
ArtifactRewardController.GrantCatalogForTests = true;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
RunManager.ForceProductionModeForTests = false;
|
||||
ArtifactRewardController.GrantCatalogForTests = false;
|
||||
Time.timeScale = 1f;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EnemyStatusVisual_LoadsBothSheetsAndSharesGroundAnchor()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
EnemyController enemy = FindOrSpawnEnemy();
|
||||
Assert.That(enemy == null, Is.False);
|
||||
|
||||
SpriteRenderer source = enemy.GetComponent<SpriteRenderer>();
|
||||
Assert.That(source == null, Is.False);
|
||||
enemy.transform.localScale = Vector3.one * 0.8f;
|
||||
source.flipX = true;
|
||||
source.flipY = false;
|
||||
|
||||
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
|
||||
Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True);
|
||||
Assert.That(enemy.ApplyStun(2f), Is.True);
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
SpriteRenderer ignite = FindChildRenderer(enemy, IgniteVisualName);
|
||||
SpriteRenderer shock = FindChildRenderer(enemy, ShockVisualName);
|
||||
Assert.That(ignite == null, Is.False);
|
||||
Assert.That(shock == null, Is.False);
|
||||
Assert.That(ignite.sprite == null, Is.False);
|
||||
Assert.That(shock.sprite == null, Is.False);
|
||||
Assert.That(ignite.sprite.rect.width, Is.EqualTo(64f).Within(0.01f));
|
||||
Assert.That(ignite.sprite.rect.height, Is.EqualTo(64f).Within(0.01f));
|
||||
Assert.That(shock.sprite.rect.width, Is.EqualTo(64f).Within(0.01f));
|
||||
Assert.That(shock.sprite.rect.height, Is.EqualTo(64f).Within(0.01f));
|
||||
Assert.That(ignite.sprite.pixelsPerUnit, Is.EqualTo(32f).Within(0.01f));
|
||||
Assert.That(shock.sprite.pixelsPerUnit, Is.EqualTo(32f).Within(0.01f));
|
||||
|
||||
Assert.That(
|
||||
Vector3.Distance(
|
||||
ignite.transform.position,
|
||||
(Vector3)enemy.GroundAnchorPosition),
|
||||
Is.LessThan(0.0001f));
|
||||
Assert.That(
|
||||
Vector3.Distance(
|
||||
shock.transform.position,
|
||||
(Vector3)enemy.GroundAnchorPosition),
|
||||
Is.LessThan(0.0001f));
|
||||
Assert.That(ignite.flipX, Is.EqualTo(source.flipX));
|
||||
Assert.That(shock.flipX, Is.EqualTo(source.flipX));
|
||||
Assert.That(ignite.sortingLayerID, Is.EqualTo(source.sortingLayerID));
|
||||
Assert.That(shock.sortingLayerID, Is.EqualTo(source.sortingLayerID));
|
||||
Assert.That(
|
||||
ignite.sortingOrder,
|
||||
Is.EqualTo(source.sortingOrder + ArtifactStatusVisual.StatusSortingOffset));
|
||||
Assert.That(
|
||||
shock.sortingOrder,
|
||||
Is.EqualTo(source.sortingOrder + ArtifactStatusVisual.StatusSortingOffset));
|
||||
Assert.That(
|
||||
ignite.transform.lossyScale.x,
|
||||
Is.EqualTo(source.transform.lossyScale.x).Within(0.001f));
|
||||
Assert.That(
|
||||
shock.transform.lossyScale.x,
|
||||
Is.EqualTo(source.transform.lossyScale.x).Within(0.001f));
|
||||
Assert.That(FindChildRendererCount(enemy, IgniteVisualName), Is.EqualTo(1));
|
||||
Assert.That(FindChildRendererCount(enemy, ShockVisualName), Is.EqualTo(1));
|
||||
Assert.That(enemy.transform.Find("Stun Runes") == null, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EnemyStatusVisual_FreezesDuringPauseAndAdvancesAfterResume()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
EnemyController enemy = FindOrSpawnEnemy();
|
||||
Assert.That(enemy == null, Is.False);
|
||||
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
|
||||
yield return null;
|
||||
|
||||
SpriteRenderer ignite = FindChildRenderer(enemy, IgniteVisualName);
|
||||
Assert.That(ignite == null, Is.False);
|
||||
Sprite frameBeforePause = ignite.sprite;
|
||||
|
||||
Time.timeScale = 0f;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(ignite == null, Is.False);
|
||||
Assert.That(ignite.sprite == frameBeforePause, Is.True);
|
||||
|
||||
Time.timeScale = 1f;
|
||||
yield return new WaitForSeconds(0.19f);
|
||||
Assert.That(ignite.sprite == frameBeforePause, Is.False);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator EnemyStatusVisual_ClearsOnExpiryDeathAndDisable()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
EnemyController expiringEnemy = FindOrSpawnEnemy();
|
||||
Assert.That(expiringEnemy == null, Is.False);
|
||||
Assert.That(expiringEnemy.ApplyIgnite(0.12f, 0.5f, 0.01f), Is.True);
|
||||
Assert.That(expiringEnemy.ApplyShock(0.12f, 0.2f), Is.True);
|
||||
yield return new WaitForSeconds(0.3f);
|
||||
Assert.That(FindChildRenderer(expiringEnemy, IgniteVisualName) == null, Is.True);
|
||||
Assert.That(FindChildRenderer(expiringEnemy, ShockVisualName) == null, Is.True);
|
||||
|
||||
EnemyController dyingEnemy = FindOrSpawnEnemy();
|
||||
Assert.That(dyingEnemy == null, Is.False);
|
||||
Assert.That(dyingEnemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
|
||||
yield return null;
|
||||
Assert.That(FindChildRenderer(dyingEnemy, IgniteVisualName) == null, Is.False);
|
||||
Assert.That(
|
||||
dyingEnemy.TryTakeDamage(
|
||||
dyingEnemy.CurrentHealth + 1f,
|
||||
Vector2.zero,
|
||||
0f),
|
||||
Is.True);
|
||||
Assert.That(dyingEnemy.IsDead, Is.True);
|
||||
yield return null;
|
||||
Assert.That(FindChildRenderer(dyingEnemy, IgniteVisualName) == null, Is.True);
|
||||
|
||||
EnemyController disabledEnemy = FindOrSpawnEnemy();
|
||||
Assert.That(disabledEnemy == null, Is.False);
|
||||
Assert.That(disabledEnemy.ApplyShock(3f, 0.2f), Is.True);
|
||||
yield return null;
|
||||
disabledEnemy.gameObject.SetActive(false);
|
||||
yield return null;
|
||||
Assert.That(FindChildRenderer(disabledEnemy, ShockVisualName) == null, Is.True);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneGhosts_CopyPlayerSpriteAndRespectMovementPauseAndExpiry()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(player == null, Is.False);
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
CombatFeedback feedback = player.GetComponent<CombatFeedback>();
|
||||
SpriteRenderer playerRenderer = player.GetComponent<SpriteRenderer>();
|
||||
Animator playerAnimator = player.GetComponent<Animator>();
|
||||
Assert.That(stats == null, Is.False);
|
||||
Assert.That(artifacts == null, Is.False);
|
||||
Assert.That(feedback == null, Is.False);
|
||||
Assert.That(playerRenderer == null, Is.False);
|
||||
Assert.That(playerAnimator == null, Is.False);
|
||||
|
||||
playerAnimator.enabled = false;
|
||||
Sprite expectedGhostSprite = playerRenderer.sprite;
|
||||
Assert.That(expectedGhostSprite == null, Is.False);
|
||||
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
|
||||
ActiveArtifactDefinition cyclone = artifacts.CurrentArtifact;
|
||||
FieldInfo artifactIdField = typeof(ActiveArtifactDefinition).GetField(
|
||||
"artifactId",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(artifactIdField, Is.Not.Null);
|
||||
string originalArtifactId = cyclone.ArtifactId;
|
||||
const string testArtifactId = "cyclone-afterimage-test";
|
||||
bool used;
|
||||
try
|
||||
{
|
||||
artifactIdField.SetValue(cyclone, testArtifactId);
|
||||
used = artifacts.TryUseCurrent(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
artifactIdField.SetValue(cyclone, originalArtifactId);
|
||||
}
|
||||
|
||||
Assert.That(used, Is.True);
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True);
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
testArtifactId + ".move-speed",
|
||||
CharacterStat.MoveSpeed),
|
||||
Is.GreaterThan(0));
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
player.transform.position += Vector3.right * 0.02f;
|
||||
yield return new WaitForSeconds(0.02f);
|
||||
}
|
||||
|
||||
SpriteRenderer ghost = FindFirstRenderer(CycloneGhostName);
|
||||
Assert.That(ghost == null, Is.False);
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.LessThanOrEqualTo(4));
|
||||
Assert.That(ghost.sprite == expectedGhostSprite, Is.True);
|
||||
Assert.That(ghost.flipX, Is.EqualTo(playerRenderer.flipX));
|
||||
Assert.That(ghost.flipY, Is.EqualTo(playerRenderer.flipY));
|
||||
Assert.That(ghost.sortingLayerID, Is.EqualTo(playerRenderer.sortingLayerID));
|
||||
Assert.That(ghost.sortingOrder, Is.EqualTo(playerRenderer.sortingOrder - 1));
|
||||
Assert.That(
|
||||
ghost.transform.lossyScale.x,
|
||||
Is.EqualTo(player.transform.lossyScale.x).Within(0.001f));
|
||||
|
||||
int countBeforePause = CountRenderers(CycloneGhostName);
|
||||
Time.timeScale = 0f;
|
||||
player.transform.position += Vector3.right * 0.5f;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(countBeforePause));
|
||||
Time.timeScale = 1f;
|
||||
|
||||
int countBeforeStop = CountRenderers(CycloneGhostName);
|
||||
yield return new WaitForSeconds(0.12f);
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.LessThanOrEqualTo(countBeforeStop));
|
||||
|
||||
yield return new WaitForSeconds(3.2f);
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(0));
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.False);
|
||||
Assert.That(
|
||||
stats.GetStackCount(
|
||||
testArtifactId + ".move-speed",
|
||||
CharacterStat.MoveSpeed),
|
||||
Is.Zero);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CycloneGhosts_ClearWhenPlayerDiesOrDisables()
|
||||
{
|
||||
yield return LoadCombatPrototype();
|
||||
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
||||
Assert.That(player == null, Is.False);
|
||||
PlayerStats stats = player.GetComponent<PlayerStats>();
|
||||
ActiveArtifactController artifacts =
|
||||
player.GetComponent<ActiveArtifactController>();
|
||||
|
||||
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
|
||||
Assert.That(artifacts.TryUseCurrent(false), Is.True);
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True);
|
||||
player.transform.position += Vector3.right * 0.2f;
|
||||
yield return null;
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.GreaterThan(0));
|
||||
|
||||
player.GetComponent<PlayerHealth>().TryTakeDamage(
|
||||
player.GetComponent<PlayerHealth>().CurrentHealth + 1f,
|
||||
Vector2.zero,
|
||||
0f);
|
||||
yield return null;
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(0));
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.False);
|
||||
|
||||
Assert.That(stats == null, Is.False);
|
||||
Assert.That(artifacts == null, Is.False);
|
||||
player.gameObject.SetActive(false);
|
||||
yield return null;
|
||||
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(0));
|
||||
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.False);
|
||||
}
|
||||
|
||||
private static IEnumerator LoadCombatPrototype()
|
||||
{
|
||||
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
|
||||
while (!load.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private static EnemyController FindOrSpawnEnemy()
|
||||
{
|
||||
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsInactive.Exclude,
|
||||
FindObjectsSortMode.InstanceID);
|
||||
foreach (EnemyController enemy in enemies)
|
||||
{
|
||||
if (enemy != null && !enemy.IsDead && enemy.gameObject.activeInHierarchy)
|
||||
{
|
||||
return enemy;
|
||||
}
|
||||
}
|
||||
|
||||
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
|
||||
if (director == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
director.DebugSpawnImmediate(1);
|
||||
enemies = Object.FindObjectsByType<EnemyController>(
|
||||
FindObjectsInactive.Exclude,
|
||||
FindObjectsSortMode.InstanceID);
|
||||
foreach (EnemyController enemy in enemies)
|
||||
{
|
||||
if (enemy != null && !enemy.IsDead && enemy.gameObject.activeInHierarchy)
|
||||
{
|
||||
return enemy;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SpriteRenderer FindChildRenderer(
|
||||
EnemyController enemy,
|
||||
string objectName)
|
||||
{
|
||||
if (enemy == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
SpriteRenderer[] renderers =
|
||||
enemy.GetComponentsInChildren<SpriteRenderer>(true);
|
||||
foreach (SpriteRenderer renderer in renderers)
|
||||
{
|
||||
if (renderer != null && renderer.gameObject.name == objectName)
|
||||
{
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int FindChildRendererCount(
|
||||
EnemyController enemy,
|
||||
string objectName)
|
||||
{
|
||||
if (enemy == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
SpriteRenderer[] renderers =
|
||||
enemy.GetComponentsInChildren<SpriteRenderer>(true);
|
||||
foreach (SpriteRenderer renderer in renderers)
|
||||
{
|
||||
if (renderer != null && renderer.gameObject.name == objectName)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static SpriteRenderer FindFirstRenderer(string objectName)
|
||||
{
|
||||
SpriteRenderer[] renderers =
|
||||
Object.FindObjectsByType<SpriteRenderer>(
|
||||
FindObjectsInactive.Include,
|
||||
FindObjectsSortMode.None);
|
||||
foreach (SpriteRenderer renderer in renderers)
|
||||
{
|
||||
if (renderer != null && renderer.gameObject.name == objectName)
|
||||
{
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int CountRenderers(string objectName)
|
||||
{
|
||||
int count = 0;
|
||||
SpriteRenderer[] renderers =
|
||||
Object.FindObjectsByType<SpriteRenderer>(
|
||||
FindObjectsInactive.Include,
|
||||
FindObjectsSortMode.None);
|
||||
foreach (SpriteRenderer renderer in renderers)
|
||||
{
|
||||
if (renderer != null && renderer.gameObject.name == objectName)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void SelectArtifact(
|
||||
ActiveArtifactController artifacts,
|
||||
ActiveArtifactEffect effect)
|
||||
{
|
||||
Assert.That(artifacts.OwnedArtifactCount, Is.GreaterThanOrEqualTo(2));
|
||||
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
|
||||
{
|
||||
if (artifacts.CurrentArtifact != null
|
||||
&& artifacts.CurrentArtifact.Effect == effect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.That(artifacts.SelectNext(), Is.True);
|
||||
}
|
||||
|
||||
Assert.Fail($"Artifact {effect} was not found in the debug catalog.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7b6c2d18e1a74fb3a0c9d5e8f4b21763
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e8f0b3c6d1a4f79b5c2e7a9d4f81630
|
||||
Reference in New Issue
Block a user