1140 lines
63 KiB
C#
1140 lines
63 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using BumpCombat.Combat;
|
|
using BumpCombat.Constants;
|
|
using BumpCombat.Core;
|
|
using BumpCombat.Defense;
|
|
using BumpCombat.Defense.Comparison;
|
|
using BumpCombat.Player;
|
|
using NUnit.Framework;
|
|
using UnityEditor;
|
|
using UnityEditor.SceneManagement;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using UnityEngine.InputSystem.LowLevel;
|
|
using UnityEngine.Rendering;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.TestTools;
|
|
using UnityEngine.UI;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace BumpCombat.Tests
|
|
{
|
|
public sealed class Defense25DComparisonPlayModeTests
|
|
{
|
|
private Defense25DComparison world;
|
|
private Defense25DComparisonSettings settings;
|
|
private float previousTimeScale;
|
|
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
|
|
|
[UnitySetUp]
|
|
public IEnumerator SetUp()
|
|
{
|
|
previousTimeScale = Time.timeScale;
|
|
Time.timeScale = 5f;
|
|
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
|
|
Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first.");
|
|
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
|
|
"Assets/_Project/Constants/Enemies/Slime.asset");
|
|
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
|
|
settings.EnemyRadius = .24f;
|
|
settings.EnemyColliderOffset = new Vector2(0f, -.28f);
|
|
settings.EnemyVisualScale = 1f;
|
|
settings.EnemyFootOffset = -.4f;
|
|
settings.EnemyBodyCenterCorrectionPixels = 0f;
|
|
world = new GameObject("Comparison under test").AddComponent<Defense25DComparison>();
|
|
world.Initialize(settings);
|
|
world.AutoSpawn = false;
|
|
world.ReadInput = false;
|
|
world.Player.ResetAt(Vector2.zero);
|
|
yield return new WaitForFixedUpdate();
|
|
}
|
|
|
|
[UnityTearDown]
|
|
public IEnumerator TearDown()
|
|
{
|
|
if (world != null) Object.Destroy(world.gameObject);
|
|
if (settings != null) Object.Destroy(settings);
|
|
Time.timeScale = previousTimeScale;
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ResonantCrystalRepeatExpiresWhileDeadAndDoesNotReturnOnRevive()
|
|
{
|
|
var relicTuning = GameplayConstants.Current.PassiveRelics;
|
|
float previousChance = relicTuning.ResonantCrystalChance;
|
|
float previousDelay = relicTuning.ResonantCrystalDelaySeconds;
|
|
try
|
|
{
|
|
relicTuning.ResonantCrystalChance = 1f;
|
|
relicTuning.ResonantCrystalDelaySeconds = .2f;
|
|
CompleteStartupArtifactSelection(world);
|
|
ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
|
Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True);
|
|
Assert.That(world.Loadout.TryAcquireRelic(BumpCombat.Progression.PassiveRelicId.ResonantCrystal), Is.True);
|
|
world.Loadout.AddGauge(artifact.NormalGaugeCost);
|
|
Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(artifact.NormalGaugeCost));
|
|
world.RelicRuntime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false),
|
|
Vector3.zero, Vector3.right, world.Player.CurrentHealth);
|
|
Assert.That(world.RelicRuntime.IsRepeatPending, Is.True);
|
|
|
|
Assert.That(world.Player.TakeDamage(world.Player.MaxHealth * 10f, Vector2.left), Is.True);
|
|
Assert.That(world.Player.IsAlive, Is.False);
|
|
for (int i = 0; i < 30 && world.RelicRuntime.IsRepeatPending; i++)
|
|
yield return new WaitForFixedUpdate();
|
|
|
|
Assert.That(world.Player.IsAlive, Is.False, "The repeat deadline must pass before the five-second revive.");
|
|
Assert.That(world.RelicRuntime.IsRepeatPending, Is.False,
|
|
"A repeat whose delay expires at zero health is discarded, not deferred to revival.");
|
|
float reviveDeadline = world.GameTime + GameplayConstants.Current.Defense.PlayerReviveDelaySeconds + 1f;
|
|
float realtimeDeadline = Time.realtimeSinceStartup + 10f;
|
|
while (!world.Player.IsAlive && world.GameTime < reviveDeadline
|
|
&& Time.realtimeSinceStartup < realtimeDeadline)
|
|
yield return null;
|
|
|
|
Assert.That(world.Player.IsAlive, Is.True);
|
|
Assert.That(world.RelicRuntime.IsRepeatPending, Is.False);
|
|
}
|
|
finally
|
|
{
|
|
relicTuning.ResonantCrystalChance = previousChance;
|
|
relicTuning.ResonantCrystalDelaySeconds = previousDelay;
|
|
}
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ArtifactHitRelicStatusTargetsResolveTheActualDefenseEnemy()
|
|
{
|
|
var relicTuning = GameplayConstants.Current.PassiveRelics;
|
|
float previousChance = relicTuning.EmberWickChance;
|
|
try
|
|
{
|
|
relicTuning.EmberWickChance = 1f;
|
|
CompleteStartupArtifactSelection(world);
|
|
ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
|
|
Assert.That(world.Loadout.TryAddArtifact(cyclone), Is.True);
|
|
Assert.That(world.Loadout.TryAcquireRelic(
|
|
BumpCombat.Progression.PassiveRelicId.EmberWick), Is.True);
|
|
world.Loadout.AddGauge(cyclone.NormalGaugeCost);
|
|
Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(cyclone.NormalGaugeCost));
|
|
Defense25DEnemy enemy = world.Enemies[0];
|
|
Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false,
|
|
settings.PlayerVisualScale);
|
|
enemy.Spawn(center);
|
|
Physics.SyncTransforms();
|
|
|
|
Assert.That(world.Loadout.TryUse(false), Is.True);
|
|
world.ArtifactExecutor.Step(cyclone.NormalDuration + .05f);
|
|
|
|
Assert.That(enemy.IgniteRemaining, Is.GreaterThan(0f),
|
|
"An artifact-originated relic hit must resolve the enemy's live status target.");
|
|
}
|
|
finally
|
|
{
|
|
relicTuning.EmberWickChance = previousChance;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator BumpTriggeredResonanceReplayDoesNotInvalidateWorldQueryTargets()
|
|
{
|
|
var relicTuning = GameplayConstants.Current.PassiveRelics;
|
|
float previousChance = relicTuning.ResonanceEchoChance;
|
|
float previousCooldown = relicTuning.ResonanceEchoCooldownSeconds;
|
|
try
|
|
{
|
|
relicTuning.ResonanceEchoChance = 1f;
|
|
relicTuning.ResonanceEchoCooldownSeconds = 0f;
|
|
ActiveArtifactDefinition chain = world.Loadout.FindArtifact(ActiveArtifactEffect.ChainLightning);
|
|
Assert.That(world.Loadout.TryAddArtifact(chain), Is.True);
|
|
Assert.That(world.Loadout.TryAcquireRelic(
|
|
BumpCombat.Progression.PassiveRelicId.ResonanceCore), Is.True);
|
|
Defense25DEnemy first = world.Enemies[0];
|
|
Defense25DEnemy second = world.Enemies[1];
|
|
first.Spawn(new Vector2(.5f, .28f));
|
|
second.Spawn(new Vector2(1f, .28f));
|
|
Physics.SyncTransforms();
|
|
Assert.That(world.Player.GapTo(second.Actor),
|
|
Is.GreaterThan(settings.ContactSensorMargin),
|
|
"The second enemy must be reached by the replay, not by the ordinary bump.");
|
|
bool ordinaryBumpResolved = false;
|
|
bool echoHitSecondEnemy = false;
|
|
void CaptureHit(CombatHitResult hit)
|
|
{
|
|
ordinaryBumpResolved |= hit.Target == first.gameObject && hit.IsOrdinaryBump;
|
|
echoHitSecondEnemy |= hit.Target == second.gameObject
|
|
&& hit.SourceId == Defense25DRelicRuntime.EchoSourceId;
|
|
}
|
|
CombatEvents.OnValidHit += CaptureHit;
|
|
|
|
try
|
|
{
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
|
|
Assert.That(world.BumpEvents, Is.GreaterThan(0), "The initial ordinary bump must resolve.");
|
|
Assert.That(ordinaryBumpResolved, Is.True);
|
|
Assert.That(first.Actor.DamageEvents, Is.GreaterThan(0));
|
|
Assert.That(second.Actor.DamageEvents, Is.GreaterThan(0));
|
|
Assert.That(echoHitSecondEnemy, Is.True,
|
|
"The synchronous echo chain must query and hit the second enemy without invalidating the bump snapshot.");
|
|
}
|
|
finally
|
|
{
|
|
CombatEvents.OnValidHit -= CaptureHit;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
relicTuning.ResonanceEchoChance = previousChance;
|
|
relicTuning.ResonanceEchoCooldownSeconds = previousCooldown;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
private GameObject Wall(Vector2 position, Vector3 size)
|
|
{
|
|
GameObject wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
|
wall.transform.SetParent(world.transform);
|
|
wall.layer = Defense25DComparisonSettings.TerrainLayer;
|
|
wall.transform.position = Defense25DActor.OnGround(position, settings.GroundY + size.y * .5f);
|
|
wall.transform.localScale = size;
|
|
Physics.SyncTransforms();
|
|
return wall;
|
|
}
|
|
|
|
private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack)
|
|
{
|
|
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
|
|
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
|
|
.OfType<Sprite>()
|
|
.OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0)
|
|
.ToArray();
|
|
return new ComparisonSpriteFrames
|
|
{
|
|
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack),
|
|
Hurt = Read("Hurt"), Death = Read("Death")
|
|
};
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SavedSceneStartsFromStoppedTimeAndReadsWasd()
|
|
{
|
|
Object.Destroy(world.gameObject);
|
|
yield return null;
|
|
// The original project's TimeManager currently starts at zero. Do not
|
|
// hide that startup condition with the fixture's accelerated clock.
|
|
Time.timeScale = 0f;
|
|
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
|
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
|
yield return null;
|
|
Assert.That(world, Is.Not.Null);
|
|
CompleteStartupArtifactSelection(world);
|
|
Assert.That(Time.timeScale, Is.EqualTo(1f), "The independent scene must start its own clock even when the project starts paused.");
|
|
Assert.That(world.ReadInput && world.AutoSpawn && world.IsRunning && !world.Paused, Is.True);
|
|
|
|
float started = world.GameTime;
|
|
yield return new WaitForSecondsRealtime(.15f);
|
|
Assert.That(world.GameTime, Is.GreaterThan(started));
|
|
Assert.That(world.Ledger.SuppliedCount, Is.GreaterThan(0), "Enemies must spawn without any key press.");
|
|
Defense25DEnemy enemy = world.Enemies[0];
|
|
Vector2 enemyBefore = enemy.Actor.Position;
|
|
yield return new WaitForSecondsRealtime(.15f);
|
|
Assert.That(Vector2.Distance(enemyBefore, enemy.Actor.Position), Is.GreaterThan(.01f), "The route must advance automatically.");
|
|
|
|
Keyboard previousKeyboard = Keyboard.current;
|
|
Keyboard keyboard = null;
|
|
InputSettings previousInputSettings = InputSystem.settings;
|
|
InputSettings testInputSettings = Object.Instantiate(previousInputSettings);
|
|
try
|
|
{
|
|
// Batch-mode Game View has no focus. Scope routing to this test,
|
|
// preserving the normal InputSystem/Update/FixedUpdate loop.
|
|
testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
|
testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus;
|
|
InputSystem.settings = testInputSettings;
|
|
keyboard = InputSystem.AddDevice<Keyboard>();
|
|
foreach (Key key in new[] { Key.UpArrow, Key.LeftArrow, Key.DownArrow, Key.RightArrow })
|
|
{
|
|
Vector2 before = world.Player.Position;
|
|
Vector2 direction = key == Key.UpArrow ? Vector2.up
|
|
: key == Key.LeftArrow ? Vector2.left
|
|
: key == Key.DownArrow ? Vector2.down : Vector2.right;
|
|
yield return HoldKey(keyboard, key);
|
|
Vector2 moved = world.Player.Position - before;
|
|
Assert.That(Vector2.Dot(moved, world.View.KeyboardToGround(direction)), Is.GreaterThan(.05f), $"{key} must move the saved-scene player in screen-relative direction. Moved={moved}");
|
|
Debug.Log($"COMPARISON_WASD key={key} moved={moved} gameTime={world.GameTime:0.000}");
|
|
}
|
|
yield return HoldKey(keyboard, Key.P);
|
|
Assert.That(world.Paused, Is.True);
|
|
Vector2 pausedPosition = world.Player.Position;
|
|
float pausedTime = world.GameTime;
|
|
yield return HoldKey(keyboard, Key.W);
|
|
Assert.That(world.Player.Position, Is.EqualTo(pausedPosition));
|
|
Assert.That(world.GameTime, Is.EqualTo(pausedTime));
|
|
yield return HoldKey(keyboard, Key.P);
|
|
Assert.That(world.Paused, Is.False);
|
|
Assert.That(world.GameTime, Is.GreaterThan(pausedTime));
|
|
yield return HoldKey(keyboard, Key.P);
|
|
yield return HoldKey(keyboard, Key.R);
|
|
CompleteStartupArtifactSelection(world);
|
|
Assert.That(world.IsRunning && !world.Paused, Is.True);
|
|
Assert.That(world.GameTime, Is.LessThan(pausedTime));
|
|
Assert.That(world.Player.Position, Is.EqualTo(world.Settings.PlayerSpawn));
|
|
Assert.That(Time.timeScale, Is.EqualTo(1f));
|
|
}
|
|
finally
|
|
{
|
|
if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard);
|
|
InputSystem.settings = previousInputSettings;
|
|
Object.Destroy(testInputSettings);
|
|
if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent();
|
|
}
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SavedSceneUsesDefenseSkeletonAndBothPortalRoutesReachTheBase()
|
|
{
|
|
Object.Destroy(world.gameObject);
|
|
yield return null;
|
|
Time.timeScale = 1f;
|
|
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
|
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
|
yield return null;
|
|
|
|
Assert.That(world, Is.Not.Null);
|
|
CompleteStartupArtifactSelection(world);
|
|
Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton"));
|
|
Assert.That(world.Settings.EnemyArt.Idle.Length, Is.GreaterThan(0));
|
|
Assert.That(world.Settings.EnemyArt.Walk.Length, Is.GreaterThan(0));
|
|
Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0));
|
|
Assert.That(world.Settings.EnemyArt.Idle[0].name, Does.StartWith("Skeleton_Idle"));
|
|
Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack"));
|
|
Assert.That(world.Settings.EnemyRadius, Is.EqualTo(.275f).Within(.0001f));
|
|
Assert.That(world.Settings.EnemyColliderOffset, Is.EqualTo(new Vector2(0f, -.35f)));
|
|
Assert.That(world.Settings.EnemyVisualScale, Is.EqualTo(1.25f));
|
|
Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null);
|
|
Assert.That(world.GetRouteForLane(0)[0], Is.EqualTo(GameplayConstants.Current.Defense.GetPortalPosition(0)));
|
|
Assert.That(world.GetRouteForLane(1)[0], Is.EqualTo(GameplayConstants.Current.Defense.GetPortalPosition(1)));
|
|
Assert.That(GameObject.Find("South obstacle"), Is.Null,
|
|
"The legacy obstacle must be disabled so it cannot block the lower defense route.");
|
|
|
|
Defense25DEnemy upperWaveEnemy = world.SpawnNextEnemy();
|
|
Defense25DEnemy lowerWaveEnemy = world.SpawnNextEnemy();
|
|
Assert.That(upperWaveEnemy.IsAvailable && lowerWaveEnemy.IsAvailable, Is.True,
|
|
"Both lanes must have living Skeleton actors before the wave capture.");
|
|
Assert.That(new[] { upperWaveEnemy.Lane, lowerWaveEnemy.Lane }, Is.EqualTo(new[] { 0, 1 }));
|
|
Time.timeScale = 1f;
|
|
yield return new WaitForSeconds(.75f);
|
|
Assert.That(upperWaveEnemy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_"));
|
|
Assert.That(lowerWaveEnemy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_"));
|
|
Capture("11-saved-scene-two-route-skeleton-wave-active");
|
|
|
|
typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth))
|
|
.GetSetMethod(true).Invoke(world, new object[] { 100f });
|
|
world.Player.ResetAt(new Vector2(0f, 5f));
|
|
Time.timeScale = 8f;
|
|
yield return new WaitForSecondsRealtime(5f);
|
|
|
|
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(16), "The saved scene must supply the complete 2x8 portal wave.");
|
|
Assert.That(world.Enemies.Count(e => e.Lane == 0), Is.EqualTo(8));
|
|
Assert.That(world.Enemies.Count(e => e.Lane == 1), Is.EqualTo(8));
|
|
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(16), "Both authored routes must reach the shared base.");
|
|
Assert.That(world.Ledger.KillEventCount, Is.Zero, "All 16 resolutions must be arrivals, not deaths.");
|
|
Assert.That(world.BaseHealth, Is.EqualTo(100f - 16f * GameplayConstants.Current.Defense.ArrivalBaseDamage));
|
|
Assert.That(world.IsRunning, Is.True, "The elevated health is limited to this route-isolation fixture.");
|
|
Assert.That(world.RunCleared, Is.False, "The run must remain active until the field rewards are collected.");
|
|
Capture("10-saved-scene-two-route-skeleton-wave");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator PKeyCannotResumeTheRunWhileAFieldRelicChoiceIsOpen()
|
|
{
|
|
CompleteStartupArtifactSelection(world);
|
|
typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth))
|
|
.GetSetMethod(true).Invoke(world, new object[] { 100f });
|
|
var laneZeroEnemies = new System.Collections.Generic.List<Defense25DEnemy>();
|
|
Defense25DEnemy movingEnemy = null;
|
|
for (int i = 0; i < GameplayConstants.Current.Defense.PortalEnemyCount * 2; i++)
|
|
{
|
|
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
|
Assert.That(enemy, Is.Not.Null);
|
|
if (enemy.Lane == 0) laneZeroEnemies.Add(enemy);
|
|
else if (movingEnemy == null) movingEnemy = enemy;
|
|
}
|
|
Assert.That(movingEnemy, Is.Not.Null);
|
|
foreach (Defense25DEnemy enemy in laneZeroEnemies)
|
|
world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival);
|
|
|
|
world.Player.ResetAt(GameplayConstants.Current.Defense.GetPortalPosition(0) + Vector2.up * 1.2f);
|
|
world.ReadInput = true;
|
|
Physics.SyncTransforms();
|
|
float modalDeadline = Time.realtimeSinceStartup + 3f;
|
|
while (!world.HasPendingFieldRelicReward && Time.realtimeSinceStartup < modalDeadline)
|
|
yield return new WaitForFixedUpdate();
|
|
Assert.That(world.HasPendingFieldRelicReward, Is.True,
|
|
"Collecting an earned portal reward must open the actual field relic modal.");
|
|
Assert.That(world.Paused, Is.True);
|
|
|
|
Keyboard previousKeyboard = Keyboard.current;
|
|
InputSettings previousInputSettings = InputSystem.settings;
|
|
InputSettings testInputSettings = Object.Instantiate(previousInputSettings);
|
|
Keyboard keyboard = null;
|
|
try
|
|
{
|
|
testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
|
testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus;
|
|
InputSystem.settings = testInputSettings;
|
|
keyboard = InputSystem.AddDevice<Keyboard>();
|
|
float pausedTime = world.GameTime;
|
|
Vector2 pausedEnemyPosition = movingEnemy.Actor.Position;
|
|
|
|
yield return HoldKey(keyboard, Key.P);
|
|
Assert.That(world.HasPendingFieldRelicReward, Is.True);
|
|
Assert.That(world.Paused, Is.True, "P must not toggle the blocking field-choice modal.");
|
|
yield return new WaitForSecondsRealtime(.2f);
|
|
Assert.That(world.GameTime, Is.EqualTo(pausedTime).Within(.0001f));
|
|
Assert.That(movingEnemy.Actor.Position, Is.EqualTo(pausedEnemyPosition),
|
|
"An active route enemy must remain frozen while the field choice is open.");
|
|
}
|
|
finally
|
|
{
|
|
if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard);
|
|
InputSystem.settings = previousInputSettings;
|
|
Object.Destroy(testInputSettings);
|
|
if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent();
|
|
}
|
|
}
|
|
|
|
private static IEnumerator HoldKey(Keyboard keyboard, Key key)
|
|
{
|
|
float until = Time.realtimeSinceStartup + .15f;
|
|
while (Time.realtimeSinceStartup < until)
|
|
{
|
|
InputSystem.QueueStateEvent(keyboard, new KeyboardState(key));
|
|
yield return null;
|
|
}
|
|
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
|
|
yield return null;
|
|
}
|
|
|
|
private static void CompleteStartupArtifactSelection(Defense25DComparison comparison)
|
|
{
|
|
var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
Assert.That(choose, Is.Not.Null);
|
|
for (int i = 0; comparison.Paused && i < 3; i++)
|
|
Assert.That(choose.Invoke(comparison, new object[] { 0 }), Is.EqualTo(true));
|
|
Assert.That(comparison.Paused, Is.False,
|
|
"The run fixture must finish the authored startup artifact choices before simulating play.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SavedSceneHudHandlesUnavailableViewportAndRestoresGuiMatrix()
|
|
{
|
|
Object.Destroy(world.gameObject);
|
|
yield return null;
|
|
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
|
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
|
yield return null;
|
|
world.SetPaused(true);
|
|
var drawHud = typeof(Defense25DComparison).GetMethod("DrawHud",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
Assert.That(drawHud, Is.Not.Null);
|
|
ComparisonHudTestWindow window = ScriptableObject.CreateInstance<ComparisonHudTestWindow>();
|
|
try
|
|
{
|
|
window.Show();
|
|
foreach (Vector2Int size in new[] { new Vector2Int(0, 360), new Vector2Int(640, 0), new Vector2Int(1, 1), new Vector2Int(640, 360), new Vector2Int(1399, 799) })
|
|
{
|
|
bool called = false;
|
|
Exception failure = null;
|
|
window.Draw = () =>
|
|
{
|
|
window.Draw = null;
|
|
called = true;
|
|
Matrix4x4 original = GUI.matrix;
|
|
try
|
|
{
|
|
GUI.matrix = Matrix4x4.TRS(new Vector3(3f, 5f, 0f), Quaternion.identity, new Vector3(2f, 2f, 1f));
|
|
Matrix4x4 before = GUI.matrix;
|
|
drawHud.Invoke(world, new object[] { Defense25DView.GetViewport(size.x, size.y) });
|
|
Assert.That(GUI.matrix, Is.EqualTo(before), "The HUD must restore its caller's GUI state.");
|
|
}
|
|
catch (Exception error) { failure = error; }
|
|
finally { GUI.matrix = original; }
|
|
};
|
|
window.SendEvent(new Event { type = EventType.Repaint });
|
|
Assert.That(called, Is.True, "Exercise the real HUD inside a valid IMGUI callback.");
|
|
if (failure != null) throw failure;
|
|
LogAssert.NoUnexpectedReceived();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
window.Draw = null;
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
public sealed class ComparisonHudTestWindow : EditorWindow
|
|
{
|
|
public Action Draw;
|
|
private void OnGUI() => Draw?.Invoke();
|
|
}
|
|
|
|
private Defense25DEnemy DurableTarget(Vector2 root)
|
|
{
|
|
Defense25DEnemy target = world.SpawnNextEnemy();
|
|
target.enabled = false; // Same 3D actor/hurtbox, with the route brain explicitly suspended.
|
|
target.Actor.AddModifier(new StatModifier("test-health", CharacterStat.MaxHealth, ModifierOperation.Flat, 90f));
|
|
// ResetAt deliberately clears modifiers; apply health before a damage probe by using a cloned definition instead.
|
|
var serialized = new SerializedObject(settings.Enemy = Object.Instantiate(settings.Enemy));
|
|
serialized.FindProperty("maxHealth").floatValue = 100f;
|
|
serialized.ApplyModifiedPropertiesWithoutUndo();
|
|
target.Actor.ResetAt(root);
|
|
Physics.SyncTransforms();
|
|
return target;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator CastBlocksSlidesAndReportsReal3DContact()
|
|
{
|
|
settings.GroundY = 2f;
|
|
world.Player.ResetAt(Vector2.zero);
|
|
GameObject wall = Wall(new Vector2(1f, 0f), new Vector3(.2f, 1.5f, 6f));
|
|
world.SetMovementInput(new Vector2(1f, 1f));
|
|
for (int i = 0; i < 40; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(world.Player.Position.x, Is.LessThanOrEqualTo(.9f - settings.PlayerRadius + .003f));
|
|
Assert.That(world.Player.Position.y, Is.GreaterThan(1f), "Wall slide must retain tangential movement.");
|
|
Assert.That(world.Player.Body.position.y, Is.EqualTo(2f).Within(.0001f));
|
|
Assert.That(world.Player.ContactCallbacks, Is.GreaterThan(0), "A trigger contact must actually be delivered by Physics3D.");
|
|
Assert.That(Physics.ComputePenetration(world.Player.BodyCollider, world.Player.Body.position, Quaternion.identity,
|
|
wall.GetComponent<Collider>(), wall.transform.position, wall.transform.rotation, out _, out _), Is.False);
|
|
Assert.That(world.GetComponentsInChildren<Rigidbody2D>(true), Is.Empty);
|
|
Assert.That(world.GetComponentsInChildren<Collider2D>(true), Is.Empty);
|
|
world.SetMovementInput(Vector2.right);
|
|
float gauge = world.Gauge;
|
|
for (int i = 0; i < 10; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(world.Gauge, Is.EqualTo(gauge).Within(.02f), "Holding into a wall must not charge movement gauge.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator BumpDeduplicatesColliderHitsAndHonorsTargetInterval()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
|
|
enemy.Actor.Facing = Vector2.right;
|
|
enemy.gameObject.AddComponent<BoxCollider>().size = Vector3.one * .1f;
|
|
CombatHitResult? hit = null;
|
|
void CaptureHit(CombatHitResult result) { hit = result; }
|
|
CombatEvents.OnValidHit += CaptureHit;
|
|
try
|
|
{
|
|
Physics.SyncTransforms();
|
|
Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one).Count, Is.EqualTo(1));
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(85f));
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
|
|
Assert.That(world.BumpEvents, Is.EqualTo(1));
|
|
Assert.That(world.Gauge, Is.EqualTo(GameplayConstants.Current.Artifacts.NormalBumpGaugeGain));
|
|
Assert.That(hit.HasValue, Is.True);
|
|
Assert.That(hit.Value.Attacker, Is.SameAs(world.Player.gameObject));
|
|
Assert.That(hit.Value.Target, Is.SameAs(enemy.gameObject));
|
|
Assert.That(hit.Value.IsOrdinaryBump && !hit.Value.IsArtifactHit, Is.True);
|
|
Assert.That(hit.Value.SourceId, Is.Null);
|
|
Assert.That(hit.Value.BaseBumpDamage, Is.EqualTo(15f));
|
|
Assert.That(hit.Value.KnockbackDirection,
|
|
Is.EqualTo((enemy.Actor.Position - world.Player.Position).normalized));
|
|
Assert.That(hit.Value.HitPosition.x, Is.EqualTo(enemy.Actor.Center.x - enemy.Actor.Radius).Within(.001f));
|
|
for (int i = 0; i < 15; i++) yield return new WaitForFixedUpdate();
|
|
enemy.Actor.Body.position = Defense25DActor.OnGround(new Vector2(.5f, .205f), settings.GroundY);
|
|
Physics.SyncTransforms();
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(2));
|
|
}
|
|
finally { CombatEvents.OnValidHit -= CaptureHit; }
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SlimeContactPassesThroughAndEnemyStillStopsAtTerrain()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(100.7f, 100f));
|
|
world.Player.ResetAt(new Vector2(100f, 100f));
|
|
enemy.Actor.ResetAt(new Vector2(100.7f, 100f));
|
|
Physics.SyncTransforms();
|
|
world.SetPaused(true);
|
|
Assert.That(world.Player.BodyCollider.enabled && enemy.Actor.BodyCollider.enabled, Is.True,
|
|
"Pausing the simulation must keep both physical colliders enabled.");
|
|
world.Player.Move(Vector2.right * 1.5f);
|
|
Assert.That(world.Player.LastMove.x, Is.EqualTo(1.5f).Within(.001f));
|
|
Assert.That(world.Player.MovementBlocked, Is.False, "A Slime must not block player movement.");
|
|
yield return new WaitForFixedUpdate();
|
|
Physics.SyncTransforms();
|
|
Assert.That(world.Player.BodyCollider.enabled && enemy.Actor.BodyCollider.enabled, Is.True);
|
|
Assert.That(world.Player.Position.x, Is.GreaterThan(enemy.Actor.Position.x), "The player must finish beyond the pass-through Slime.");
|
|
|
|
enemy.Actor.ResetAt(new Vector2(110f, 100f));
|
|
Wall(new Vector2(112f, 100f), new Vector3(.2f, 1.5f, 6f));
|
|
Assert.That(enemy.Actor.BodyCollider.enabled, Is.True);
|
|
enemy.Actor.Move(Vector2.right * 2f);
|
|
yield return new WaitForFixedUpdate();
|
|
Assert.That(enemy.Actor.LastMove.x, Is.GreaterThan(0f), "The enemy must move up to the wall before being blocked.");
|
|
Assert.That(enemy.Actor.LastMove.x, Is.LessThan(1.7f), "An enemy must still stop at terrain.");
|
|
Assert.That(enemy.Actor.Position.x, Is.LessThan(111.7f), "The enemy root must remain on the near side of the wall.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SlimeSideHitDoesNotRecoilOrDamageThePlayer()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
|
|
world.Player.ResetAt(Vector2.zero);
|
|
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
|
|
enemy.Actor.Facing = Vector2.up;
|
|
Physics.SyncTransforms();
|
|
HitSide classified = BumpCombatMath.ClassifySide(enemy.Actor.Facing,
|
|
world.Player.Position - enemy.Actor.Position);
|
|
Assert.That(classified, Is.EqualTo(HitSide.Side), "The chosen root positions and facing must form a side hit.");
|
|
Assert.That(world.Player.Position, Is.EqualTo(Vector2.zero));
|
|
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
|
|
Assert.That(world.Player.HasKnockback, Is.False, "Slime crowd hits must not recoil the player.");
|
|
Assert.That(world.Player.DamageEvents, Is.Zero, "A side hit must not trigger frontal self-damage.");
|
|
Assert.That(world.Player.Position, Is.EqualTo(Vector2.zero), "The player root must remain at the side-hit position.");
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator RearHitCancelsAnActiveWarningOnceInAttackThenRearOrder()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
|
|
Assert.That(enemy, Is.Not.Null);
|
|
enemy.enabled = true;
|
|
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
|
|
world.Player.ResetAt(Vector2.zero);
|
|
var events = new System.Collections.Generic.List<string>();
|
|
void OnRear(GameObject target) { if (target == enemy.gameObject) events.Add("rear"); }
|
|
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) events.Add("attack"); }
|
|
CombatEvents.OnRearAttackCancelled += OnRear;
|
|
CombatEvents.OnAttackCancelled += OnAttack;
|
|
try
|
|
{
|
|
enemy.Step(Time.fixedDeltaTime);
|
|
Assert.That(enemy.IsAttacking, Is.True);
|
|
enemy.Actor.Facing = Vector2.right;
|
|
Physics.SyncTransforms();
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
Assert.That(world.BumpEvents, Is.EqualTo(1));
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
|
|
Assert.That(enemy.Actor.IsAlive, Is.True, "The rear hit must leave the Slime available for attack-cancel checks.");
|
|
Assert.That(events, Is.EqualTo(new[] { "attack", "rear" }));
|
|
}
|
|
finally
|
|
{
|
|
CombatEvents.OnRearAttackCancelled -= OnRear;
|
|
CombatEvents.OnAttackCancelled -= OnAttack;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator IdleRearBumpDoesNotPublishCancellationEvents()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
|
|
int rearCancelled = 0, attacksCancelled = 0;
|
|
void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; }
|
|
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; }
|
|
CombatEvents.OnRearAttackCancelled += OnRear;
|
|
CombatEvents.OnAttackCancelled += OnAttack;
|
|
try
|
|
{
|
|
enemy.Actor.Facing = Vector2.right;
|
|
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
|
|
Assert.That(rearCancelled, Is.Zero);
|
|
Assert.That(attacksCancelled, Is.Zero);
|
|
}
|
|
finally
|
|
{
|
|
CombatEvents.OnRearAttackCancelled -= OnRear;
|
|
CombatEvents.OnAttackCancelled -= OnAttack;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator NaturalAttackCompletionDoesNotPublishCancellationEvents()
|
|
{
|
|
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
|
Assert.That(enemy, Is.Not.Null);
|
|
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
|
|
int rearCancelled = 0, attacksCancelled = 0;
|
|
void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; }
|
|
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; }
|
|
CombatEvents.OnRearAttackCancelled += OnRear;
|
|
CombatEvents.OnAttackCancelled += OnAttack;
|
|
try
|
|
{
|
|
enemy.Step(Time.fixedDeltaTime);
|
|
Assert.That(enemy.IsAttacking, Is.True);
|
|
for (int i = 0; i < 100 && enemy.IsAttacking; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(enemy.IsAttacking, Is.False);
|
|
Assert.That(rearCancelled, Is.Zero);
|
|
Assert.That(attacksCancelled, Is.Zero);
|
|
}
|
|
finally
|
|
{
|
|
CombatEvents.OnRearAttackCancelled -= OnRear;
|
|
CombatEvents.OnAttackCancelled -= OnAttack;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator InvalidAttackTargetCleanupDoesNotPublishCancellationEvents()
|
|
{
|
|
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
|
Assert.That(enemy, Is.Not.Null);
|
|
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
|
|
int rearCancelled = 0, attacksCancelled = 0;
|
|
void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; }
|
|
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; }
|
|
CombatEvents.OnRearAttackCancelled += OnRear;
|
|
CombatEvents.OnAttackCancelled += OnAttack;
|
|
try
|
|
{
|
|
enemy.Step(Time.fixedDeltaTime);
|
|
Assert.That(enemy.IsAttacking, Is.True);
|
|
world.Player.gameObject.SetActive(false);
|
|
enemy.Step(Time.fixedDeltaTime);
|
|
Assert.That(enemy.IsAttacking, Is.False);
|
|
Assert.That(rearCancelled, Is.Zero);
|
|
Assert.That(attacksCancelled, Is.Zero);
|
|
}
|
|
finally
|
|
{
|
|
CombatEvents.OnRearAttackCancelled -= OnRear;
|
|
CombatEvents.OnAttackCancelled -= OnAttack;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator VisualHeightDoesNotChangeFootprintAndBroadphaseHasBoundedHeight()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(1f, 0f));
|
|
Vector2 center = enemy.Actor.Center;
|
|
enemy.Actor.Visual.VisualHeight = 5f;
|
|
yield return null;
|
|
Assert.That(enemy.Actor.Center, Is.EqualTo(center));
|
|
Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one).Count, Is.EqualTo(1));
|
|
enemy.Actor.Body.position += Vector3.up * 20f;
|
|
Physics.SyncTransforms();
|
|
Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one), Is.Empty);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator CycloneKeepsDamageTicksIndependentOfPauseFramesAndDecoration()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(.7f, .205f));
|
|
foreach (bool charged in new[] { false, true })
|
|
{
|
|
world.Player.ResetAt(Vector2.zero);
|
|
enemy.Actor.ResetAt(new Vector2(.7f, .205f));
|
|
Physics.SyncTransforms();
|
|
world.DebugFillGauge();
|
|
world.CycloneVisual.DecorationSpeed = charged ? 4f : 1f;
|
|
world.CycloneVisual.Height = charged ? .65f : .15f;
|
|
world.CycloneVisual.RadiusScale = charged ? 1.35f : 1f;
|
|
Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True);
|
|
yield return new WaitForFixedUpdate();
|
|
world.SetPaused(true);
|
|
float elapsed = world.CycloneElapsed, gameTime = world.GameTime;
|
|
int events = enemy.Actor.DamageEvents;
|
|
for (int i = 0; i < 5; i++) yield return null;
|
|
Assert.That(world.CycloneElapsed, Is.EqualTo(elapsed));
|
|
Assert.That(world.GameTime, Is.EqualTo(gameTime));
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(events));
|
|
world.SetPaused(false);
|
|
for (int i = 0; i < 50 && world.CycloneActive; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(world.CycloneActive, Is.False);
|
|
Assert.That(world.CycloneTicks, Is.EqualTo(charged ? 5 : 3));
|
|
Assert.That(world.CycloneDamageEvents, Is.EqualTo(charged ? 5 : 3));
|
|
Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(charged ? 60f : 82f));
|
|
Assert.That(world.Gauge, Is.EqualTo(70f));
|
|
Assert.That(world.Player.MoveSpeed, Is.GreaterThan(GameplayConstants.Current.Player.BaseMoveSpeed));
|
|
Assert.That(world.CycloneVisual.IsVisible, Is.False);
|
|
}
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator CancelDeathDisableAndReuseLeaveNoDelayedHitsOrSummonRespawn()
|
|
{
|
|
Defense25DEnemy enemy = DurableTarget(new Vector2(.7f, .205f));
|
|
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
|
|
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
|
|
world.DebugFillGauge();
|
|
world.TryUseCyclone(true, Vector2.right);
|
|
yield return new WaitForFixedUpdate();
|
|
world.CancelCyclone();
|
|
int hits = enemy.Actor.DamageEvents;
|
|
for (int i = 0; i < 40; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(hits));
|
|
Assert.That(world.Player.MoveSpeed, Is.EqualTo(GameplayConstants.Current.Player.BaseMoveSpeed));
|
|
Defense25DSummon summon = world.Summons[0];
|
|
summon.Actor.TakeDamage(999f, Vector2.zero);
|
|
world.Player.TakeDamage(999f, Vector2.zero);
|
|
float remaining = summon.RespawnRemaining;
|
|
for (int i = 0; i < 25; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(world.TowersOperational, Is.False);
|
|
Assert.That(summon.RespawnRemaining, Is.EqualTo(remaining));
|
|
Assert.That(world.CycloneVisual.IsVisible, Is.False);
|
|
world.enabled = false;
|
|
Assert.That(world.Player.BodyCollider.enabled, Is.False);
|
|
Assert.That(world.Summons.All(s => !s.IsAttacking), Is.True);
|
|
world.enabled = true;
|
|
world.ResetRun();
|
|
yield return new WaitForFixedUpdate();
|
|
Assert.That(world.Player.IsAlive && world.Player.BodyCollider.enabled, Is.True);
|
|
Assert.That(world.Ledger.SuppliedCount, Is.Zero);
|
|
Assert.That(world.Gauge, Is.EqualTo(GameplayConstants.Current.Artifacts.StartingGauge));
|
|
Assert.That(world.Enemies.All(e => !e.gameObject.activeSelf), Is.True);
|
|
Assert.That(world.Summons, Is.Empty);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator RoutePreservesWaypointsAndArrivalCannotGrantKillRewardTwice()
|
|
{
|
|
settings.PortalImmunity = DefenseTowerKind.Summon;
|
|
world.Player.ResetAt(new Vector2(-8f, -4f));
|
|
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
|
float before = world.BaseHealth;
|
|
for (int i = 0; i < 30; i++) yield return new WaitForFixedUpdate();
|
|
int waypoint = enemy.WaypointIndex;
|
|
enemy.Actor.ApplyKnockback(Vector2.up, .5f);
|
|
yield return new WaitForFixedUpdate();
|
|
Assert.That(enemy.WaypointIndex, Is.EqualTo(waypoint));
|
|
while (enemy.IsAvailable && world.GameTime < 45f) yield return new WaitForFixedUpdate();
|
|
Assert.That(enemy.IsAvailable, Is.False, "The route enemy must reach the base.");
|
|
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(1));
|
|
Assert.That(world.Ledger.KillEventCount, Is.Zero);
|
|
Assert.That(world.KillCurrency, Is.Zero);
|
|
Assert.That(world.BaseHealth, Is.EqualTo(before - GameplayConstants.Current.Defense.ArrivalBaseDamage));
|
|
world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival);
|
|
Assert.That(world.BaseHealth, Is.EqualTo(before - GameplayConstants.Current.Defense.ArrivalBaseDamage));
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SummonHitsPhysicallyAndRespawnsWhileTowerIsOperational()
|
|
{
|
|
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0);
|
|
world.Player.ResetAt(pad);
|
|
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
|
|
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
|
|
Defense25DEnemy enemy = world.SpawnNextEnemy();
|
|
if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, enemy))
|
|
enemy = world.SpawnNextEnemy();
|
|
enemy.Actor.ResetAt(tower.Position + new Vector2(1f, 0f));
|
|
world.Player.ResetAt(pad + new Vector2(-1.5f, -1.5f));
|
|
Physics.SyncTransforms();
|
|
for (int i = 0; i < 180 && world.Summons.Sum(s => s.AttackEvents) == 0; i++) yield return new WaitForFixedUpdate();
|
|
Assert.That(world.Summons.Sum(s => s.AttackEvents), Is.GreaterThan(0));
|
|
Defense25DSummon summon = world.Summons[0];
|
|
Assert.That(Physics.GetIgnoreCollision(summon.Actor.BodyCollider, world.Player.BodyCollider), Is.True);
|
|
Assert.That(Physics.GetIgnoreCollision(summon.Actor.BodyCollider, world.Summons[1].Actor.BodyCollider), Is.False);
|
|
int slot = summon.SlotIndex;
|
|
summon.Actor.TakeDamage(999f, Vector2.zero);
|
|
Assert.That(summon.Actor.BodyCollider.enabled, Is.False);
|
|
Defense25DSummon replacement = null;
|
|
for (int i = 0; i < 180 && replacement == null; i++)
|
|
{
|
|
replacement = world.Summons.FirstOrDefault(s => s.SlotIndex == slot && s.Actor.IsAlive);
|
|
if (replacement == null) yield return new WaitForFixedUpdate();
|
|
}
|
|
Assert.That(replacement, Is.Not.Null);
|
|
Assert.That(replacement, Is.Not.SameAs(summon));
|
|
Assert.That(replacement.Actor.BodyCollider.enabled, Is.True);
|
|
Assert.That(tower.SummonCount, Is.EqualTo(tower.TargetSummonCount));
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ViewportRayAndCameraBasisPreserveGroundCoordinates()
|
|
{
|
|
GameObject cameraObject = new("Viewport mapping test");
|
|
cameraObject.transform.SetParent(world.transform);
|
|
Defense25DView display = cameraObject.AddComponent<Defense25DView>();
|
|
Camera camera = display.Camera;
|
|
camera.orthographic = true;
|
|
camera.orthographicSize = 6.5f;
|
|
camera.transform.rotation = Quaternion.Euler(50f, 35f, 0f);
|
|
camera.transform.position = Vector3.up * 2f - camera.transform.forward * 25f;
|
|
Vector2 expected = new(1f, -.5f);
|
|
Vector3 uv = camera.WorldToViewportPoint(Defense25DActor.OnGround(expected, 2f));
|
|
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
|
|
Vector2 screen = viewport.position + Vector2.Scale(new Vector2(uv.x, uv.y), viewport.size);
|
|
Assert.That(display.TryScreenToGround(screen, 2f, out Vector2 actual), Is.True);
|
|
Assert.That(Vector2.Distance(actual, expected), Is.LessThan(.001f));
|
|
Assert.That(display.TryScreenToGround(new Vector2(-1f, -1f), 2f, out _), Is.False);
|
|
Assert.That(display.KeyboardToGround(Vector2.one).magnitude, Is.EqualTo(1f).Within(.0001f));
|
|
Assert.That(Vector2.Dot(display.KeyboardToGround(Vector2.right),
|
|
Defense25DActor.Ground(camera.transform.right).normalized), Is.GreaterThan(.999f));
|
|
camera.transform.rotation = Quaternion.identity;
|
|
Assert.That(display.TryScreenToGround(viewport.center, 2f, out _), Is.False, "A parallel ray must not replace the last valid aim.");
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ComparisonScenePresentsPixelsToGameDisplayAndCleansUp()
|
|
{
|
|
Object.Destroy(world.gameObject);
|
|
yield return null;
|
|
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
|
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
|
yield return null;
|
|
world.ReadInput = false;
|
|
world.AutoSpawn = false;
|
|
world.ResetRun();
|
|
world.SetPaused(true);
|
|
Defense25DView view = world.View;
|
|
Camera output = Camera.allCameras.SingleOrDefault(c => c.targetTexture == null && c.targetDisplay == view.Camera.targetDisplay);
|
|
Assert.That(output, Is.Not.Null, "The comparison must have a camera rendering to the Game display, not only an offscreen texture and OnGUI.");
|
|
Assert.That(EditorGUIUtility.IsDisplayReferencedByCameras(output.targetDisplay), Is.True,
|
|
"Check the same display predicate that controls Unity's No cameras rendering warning.");
|
|
// Batch mode automatically renders texture cameras only. Invoke the actual Game view
|
|
// display-render path without changing the presentation camera's screen destination.
|
|
var renderDisplay = typeof(EditorGUIUtility).GetMethod("RenderPlayModeViewCamerasInternal",
|
|
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
|
|
Assert.That(renderDisplay, Is.Not.Null);
|
|
bool renderedToDisplay = false;
|
|
void Rendered(ScriptableRenderContext context, Camera camera)
|
|
{
|
|
if (camera == output) renderedToDisplay = true;
|
|
}
|
|
for (int i = 0; i < 3; i++) yield return null;
|
|
|
|
RawImage image = view.GetComponentInChildren<RawImage>();
|
|
Assert.That(image, Is.Not.Null);
|
|
Assert.That(image.canvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera));
|
|
Assert.That(image.canvas.worldCamera, Is.SameAs(output));
|
|
Assert.That(image.texture, Is.SameAs(view.Texture));
|
|
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
|
|
Assert.That(image.rectTransform.rect.size, Is.EqualTo(viewport.size));
|
|
RenderTexture presented = new(Screen.width, Screen.height, 24, RenderTextureFormat.ARGB32);
|
|
Texture2D screen = new(Screen.width, Screen.height, TextureFormat.RGB24, false);
|
|
Texture2D source = new(640, 360, TextureFormat.RGB24, false);
|
|
RenderTexture previous = RenderTexture.active;
|
|
presented.Create();
|
|
RenderPipelineManager.endCameraRendering += Rendered;
|
|
try
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
Canvas.ForceUpdateCanvases();
|
|
renderDisplay.Invoke(null, new object[] { presented, output.targetDisplay, Vector2.zero, false, false });
|
|
yield return null;
|
|
}
|
|
Assert.That(renderedToDisplay, Is.True, "The screen camera must actually render through the Game view pipeline.");
|
|
Assert.That(output.targetTexture, Is.Null);
|
|
RenderTexture.active = presented;
|
|
screen.ReadPixels(new Rect(0, 0, screen.width, screen.height), 0, 0);
|
|
screen.Apply();
|
|
// Match GameView.deviceFlippedTargetInView on top-origin graphics APIs.
|
|
// This is the Editor display target's orientation, not the world's texture UVs.
|
|
if (SystemInfo.graphicsUVStartsAtTop)
|
|
{
|
|
Color32[] pixels = screen.GetPixels32();
|
|
Color32[] upright = new Color32[pixels.Length];
|
|
for (int y = 0; y < screen.height; y++)
|
|
Array.Copy(pixels, y * screen.width, upright, (screen.height - 1 - y) * screen.width, screen.width);
|
|
screen.SetPixels32(upright);
|
|
screen.Apply();
|
|
}
|
|
RenderTexture.active = view.Texture;
|
|
source.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
|
source.Apply();
|
|
view.Camera.Render();
|
|
Color32[] sourcePixels = source.GetPixels32();
|
|
AssertCapturedGeometry(sourcePixels);
|
|
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT") ?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
|
|
Directory.CreateDirectory(directory);
|
|
File.WriteAllBytes(Path.Combine(directory, "10-display-output.png"), screen.EncodeToPNG());
|
|
File.WriteAllBytes(Path.Combine(directory, "10-display-source.png"), source.EncodeToPNG());
|
|
int matched = 0, samples = 0;
|
|
for (int y = 17; y < 360; y += 31)
|
|
for (int x = 13; x < 640; x += 29)
|
|
{
|
|
Color expected = source.GetPixel(x, y);
|
|
Color actual = screen.GetPixel(Mathf.FloorToInt(viewport.x + (x + .5f) * viewport.width / 640f),
|
|
Mathf.FloorToInt(viewport.y + (y + .5f) * viewport.height / 360f));
|
|
if (Mathf.Abs(actual.r - expected.r) < .02f && Mathf.Abs(actual.g - expected.g) < .02f && Mathf.Abs(actual.b - expected.b) < .02f) matched++;
|
|
samples++;
|
|
}
|
|
Assert.That(matched / (float)samples, Is.GreaterThan(.95f), "The display must present the scene with the same orientation and point-scaled viewport.");
|
|
if (viewport.x >= 1f || viewport.y >= 1f)
|
|
Assert.That(screen.GetPixel(0, 0).maxColorComponent, Is.LessThan(.01f), "Letterbox pixels must stay black.");
|
|
}
|
|
finally
|
|
{
|
|
RenderPipelineManager.endCameraRendering -= Rendered;
|
|
RenderTexture.active = previous;
|
|
presented.Release();
|
|
Object.Destroy(presented);
|
|
Object.Destroy(screen);
|
|
Object.Destroy(source);
|
|
}
|
|
RenderTexture ownedTexture = view.Texture;
|
|
Camera sceneCamera = view.Camera;
|
|
view.enabled = false;
|
|
yield return null;
|
|
Assert.That(output.isActiveAndEnabled, Is.False);
|
|
view.enabled = true;
|
|
yield return null;
|
|
Assert.That(output.isActiveAndEnabled, Is.True);
|
|
Assert.That(view.GetComponentsInChildren<Camera>().Length, Is.EqualTo(2), "Re-enabling must reuse the output camera.");
|
|
Object.Destroy(view);
|
|
yield return null;
|
|
yield return null;
|
|
Assert.That(output == null && ownedTexture == null, Is.True, "Removing the view must release its output camera and render texture.");
|
|
Assert.That(sceneCamera.targetTexture, Is.Null);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator CaptureIndependentSceneAndExistingTextureVariants()
|
|
{
|
|
Time.timeScale = 1f;
|
|
Object.Destroy(world.gameObject);
|
|
yield return null;
|
|
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
|
|
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
|
yield return null;
|
|
Assert.That(world, Is.Not.Null);
|
|
world.ReadInput = false;
|
|
world.AutoSpawn = false;
|
|
world.ResetRun();
|
|
world.SetPaused(true);
|
|
for (int i = 0; i < 3; i++) yield return null;
|
|
Capture("01-scene");
|
|
world.SetPaused(false);
|
|
world.DebugFillGauge();
|
|
Assert.That(world.TryUseCyclone(false, Vector2.right), Is.True, "The capture must start a real Cyclone.");
|
|
for (int i = 0; i < 6; i++) yield return new WaitForFixedUpdate();
|
|
world.SetPaused(true);
|
|
Assert.That(world.CycloneActive, Is.True, $"Cyclone ended before capture: elapsed={world.CycloneElapsed}, ticks={world.CycloneTicks}.");
|
|
Color32[] frontBackPixels = null, ribbonPixels = null;
|
|
foreach (string variant in new[] { "02-front-back-cutout", "03-front-back-alpha", "04-ribbon", "05-ribbon-radius", "06-ribbon-height", "07-ribbon-speed" })
|
|
{
|
|
world.CycloneVisual.Ribbon = string.CompareOrdinal(variant, "04") >= 0;
|
|
world.CycloneVisual.Transparent = variant.Contains("alpha");
|
|
world.CycloneVisual.RadiusScale = variant.Contains("radius") ? 1.35f : 1f;
|
|
world.CycloneVisual.Height = variant.Contains("height") ? .65f : .15f;
|
|
world.CycloneVisual.DecorationSpeed = variant.Contains("speed") ? 4f : 1f;
|
|
for (int i = 0; i < 3; i++) yield return null;
|
|
Assert.That(world.CycloneActive && world.CycloneVisual.IsVisible, Is.True, "The captured effect must still be active and visible.");
|
|
Assert.That(world.CycloneVisual.GetComponentsInChildren<SpriteRenderer>().All(r => r.sprite != null),
|
|
Is.True, "Existing Cyclone PNG frames must be loaded and assigned, not just an enabled empty renderer.");
|
|
Color32[] pixels = Capture(variant);
|
|
if (variant == "02-front-back-cutout") frontBackPixels = pixels;
|
|
else if (variant == "03-front-back-alpha" || variant == "04-ribbon")
|
|
Assert.That(DifferentPixels(frontBackPixels, pixels), Is.GreaterThan(4), variant + " must visibly differ from front/back cutout.");
|
|
if (variant == "04-ribbon") ribbonPixels = pixels;
|
|
else if (variant.StartsWith("05") || variant.StartsWith("06") || variant.StartsWith("07"))
|
|
Assert.That(DifferentPixels(ribbonPixels, pixels), Is.GreaterThan(4), variant + " must visibly change the same-texture ribbon.");
|
|
}
|
|
world.Player.Body.interpolation = RigidbodyInterpolation.None;
|
|
world.Player.Body.position = Defense25DActor.OnGround(new Vector2(-1f, 1.15f), world.Settings.GroundY);
|
|
world.Player.transform.position = world.Player.Body.position;
|
|
world.CycloneVisual.Ribbon = false;
|
|
world.CycloneVisual.DecorationSpeed = 1f;
|
|
Physics.SyncTransforms();
|
|
for (int i = 0; i < 3; i++) yield return null;
|
|
Color32[] occluded = Capture("08-wall-occlusion");
|
|
GameObject wall = GameObject.Find("Central wall - slide and occlusion");
|
|
Assert.That(wall, Is.Not.Null);
|
|
Renderer wallRenderer = wall.GetComponent<Renderer>();
|
|
wallRenderer.enabled = false;
|
|
for (int i = 0; i < 3; i++) yield return null;
|
|
Color32[] exposed = Capture("09-wall-hidden-control");
|
|
Assert.That(DifferentPixels(occluded, exposed), Is.GreaterThan(100));
|
|
Assert.That(CountCyclonePixels(exposed), Is.GreaterThan(CountCyclonePixels(occluded)),
|
|
"Removing the wall must reveal effect pixels previously hidden by real depth.");
|
|
wallRenderer.enabled = true;
|
|
}
|
|
|
|
private static int CountCyclonePixels(Color32[] pixels) => pixels.Count(p => p.g > 90 && p.g > p.r * 1.7f && p.b > p.r * 1.2f && p.b < p.g);
|
|
|
|
private static int DifferentPixels(Color32[] a, Color32[] b)
|
|
{
|
|
int count = 0;
|
|
for (int i = 0; i < a.Length; i++) if (!a[i].Equals(b[i])) count++;
|
|
return count;
|
|
}
|
|
|
|
private Color32[] Capture(string label)
|
|
{
|
|
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
|
|
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
|
|
Directory.CreateDirectory(directory);
|
|
world.View.Camera.Render();
|
|
RenderTexture previous = RenderTexture.active;
|
|
RenderTexture.active = world.View.Texture;
|
|
Texture2D image = new(640, 360, TextureFormat.RGB24, false);
|
|
image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
|
image.Apply();
|
|
RenderTexture.active = previous;
|
|
Color32[] pixels = image.GetPixels32();
|
|
AssertCapturedGeometry(pixels);
|
|
File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG());
|
|
Texture2D upscale = new(1920, 1080, TextureFormat.RGB24, false);
|
|
Color32[] large = new Color32[1920 * 1080];
|
|
for (int y = 0; y < 1080; y++)
|
|
for (int x = 0; x < 1920; x++) large[y * 1920 + x] = pixels[(y / 3) * 640 + x / 3];
|
|
upscale.SetPixels32(large);
|
|
upscale.Apply();
|
|
File.WriteAllBytes(Path.Combine(directory, label + "-1920x1080.png"), upscale.EncodeToPNG());
|
|
Object.Destroy(image);
|
|
Object.Destroy(upscale);
|
|
return pixels;
|
|
}
|
|
|
|
private void AssertCapturedGeometry(Color32[] pixels)
|
|
{
|
|
Color32 background = pixels[0];
|
|
int scenePixels = pixels.Count(pixel => !pixel.Equals(background));
|
|
Assert.That(scenePixels, Is.GreaterThan(10000),
|
|
"The saved scene capture must contain substantial rendered map geometry, not a flat clear color.");
|
|
Camera camera = world.View.Camera;
|
|
for (int pad = 0; pad < 6; pad++)
|
|
{
|
|
Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad);
|
|
Vector3 screen = camera.WorldToScreenPoint(
|
|
Defense25DActor.OnGround(ground, world.Settings.GroundY + .035f));
|
|
Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane), $"Captured build pad {pad} must be in front of the camera.");
|
|
Assert.That(screen.x, Is.InRange(8f, Defense25DView.Width - 8f), $"Captured build pad {pad} must be on-screen.");
|
|
Assert.That(screen.y, Is.InRange(8f, Defense25DView.Height - 8f), $"Captured build pad {pad} must be on-screen.");
|
|
}
|
|
|
|
Vector3 playerScreen = camera.WorldToScreenPoint(world.Player.transform.position + Vector3.up * .45f);
|
|
Assert.That(CountBrightPixels(pixels, playerScreen, 12), Is.GreaterThan(3),
|
|
"The comparison player must contribute rendered sprite pixels.");
|
|
}
|
|
|
|
private static int CountBrightPixels(Color32[] pixels, Vector3 screen, int radius)
|
|
{
|
|
int count = 0, cx = Mathf.RoundToInt(screen.x), cy = Mathf.RoundToInt(screen.y);
|
|
for (int y = -radius; y <= radius; y++)
|
|
for (int x = -radius; x <= radius; x++)
|
|
{
|
|
int px = cx + x, py = cy + y;
|
|
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
|
|
Color32 pixel = pixels[py * Defense25DView.Width + px];
|
|
if (pixel.r > 90 || pixel.g > 90 || pixel.b > 90) count++;
|
|
}
|
|
return count;
|
|
}
|
|
}
|
|
}
|