Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
@@ -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