1383 lines
78 KiB
C#
1383 lines
78 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 DenseEnemyActorsDoNotHideWallWhenMovementCastBufferFills()
|
|
{
|
|
const int blockerActorCount = 80;
|
|
world.Player.ResetAt(Vector2.zero);
|
|
for (int i = 0; i < blockerActorCount; i++)
|
|
{
|
|
GameObject actorObject = new($"Dense movement actor {i}");
|
|
actorObject.transform.SetParent(world.transform, false);
|
|
actorObject.transform.position = Defense25DActor.OnGround(
|
|
new Vector2(.55f + i * .0005f, 0f), settings.GroundY);
|
|
actorObject.AddComponent<Defense25DActor>().Configure(world, ComparisonActorKind.Enemy);
|
|
}
|
|
GameObject wall = Wall(new Vector2(1.2f, 0f), new Vector3(.2f, 1.5f, 6f));
|
|
Collider wallCollider = wall.GetComponent<Collider>();
|
|
Physics.SyncTransforms();
|
|
|
|
CapsuleCollider capsule = world.Player.BodyCollider;
|
|
float cylinder = Mathf.Max(0f, capsule.height * .5f - capsule.radius);
|
|
Vector3 center = Defense25DActor.OnGround(world.Player.Center,
|
|
settings.GroundY + capsule.center.y);
|
|
RaycastHit[] initialCapacity = new RaycastHit[64];
|
|
int initialHitCount = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
|
|
center - Vector3.up * cylinder, capsule.radius, Vector3.right,
|
|
initialCapacity, 2f + settings.MovementSkin,
|
|
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
|
|
QueryTriggerInteraction.Ignore);
|
|
Assert.That(initialHitCount, Is.EqualTo(initialCapacity.Length),
|
|
"The fixture must saturate the original movement hit capacity with real actor colliders.");
|
|
Assert.That(initialCapacity.Count(hit => hit.collider != wallCollider &&
|
|
hit.collider.name.StartsWith("Dense movement actor", StringComparison.Ordinal)),
|
|
Is.GreaterThanOrEqualTo(60),
|
|
"Real actor colliders must occupy nearly all of the original movement hit capacity.");
|
|
|
|
world.Player.Move(Vector2.right * 2f);
|
|
Assert.That(world.Player.MovementBlocked, Is.True,
|
|
"Dense pass-through actors must not hide the actual wall from the expanded movement query.");
|
|
Assert.That(world.Player.LastMovementBlocker, Is.EqualTo(wallCollider));
|
|
Assert.That(world.Player.LastMove.x, Is.GreaterThan(.5f).And.LessThan(1f),
|
|
"The player must advance through pass-through enemies, then stop before the wall.");
|
|
yield return new WaitForFixedUpdate();
|
|
Assert.That(world.Player.Position.x, Is.LessThan(1.2f - .1f + settings.MovementSkin));
|
|
}
|
|
|
|
[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;
|
|
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator CaptureCycloneFrontBackAndOrbitAtEightFramesForNormalAndCharged()
|
|
{
|
|
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; // Saved scene initialization runs in Start.
|
|
Assert.That(world, Is.Not.Null);
|
|
world.ReadInput = false;
|
|
world.AutoSpawn = false;
|
|
world.ShowBoundaries = false;
|
|
world.ResetRun();
|
|
CompleteStartupArtifactSelection(world);
|
|
world.SetPaused(false);
|
|
world.DebugFillGauge();
|
|
Assert.That(world.View, Is.Not.Null);
|
|
Camera sourceCamera = world.View.Camera;
|
|
Vector3 originalPosition = sourceCamera.transform.position;
|
|
Quaternion originalRotation = sourceCamera.transform.rotation;
|
|
float originalSize = sourceCamera.orthographicSize;
|
|
System.Reflection.FieldInfo elapsedField = typeof(Defense25DComparison).GetField(
|
|
"<CycloneElapsed>k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
Assert.That(elapsedField, Is.Not.Null, "Use paused exact elapsed values for synchronized frame captures.");
|
|
System.Reflection.FieldInfo orbitRendererField = typeof(Defense25DCycloneVisual).GetField(
|
|
"orbitRenderer", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
Assert.That(orbitRendererField, Is.Not.Null);
|
|
MeshRenderer orbitRenderer = (MeshRenderer)orbitRendererField.GetValue(world.CycloneVisual);
|
|
SpriteRenderer bodyRenderer = world.Player.Visual.Renderer;
|
|
Shader maskShader = Resources.Load<Shader>(
|
|
"Artifacts/ThreeColor-v1/CycloneOrbit25D-v1/CycloneBodyMask");
|
|
Assert.That(maskShader, Is.Not.Null);
|
|
Material orbitMaterial = orbitRenderer.sharedMaterial;
|
|
Assert.That(orbitMaterial.shader, Is.EqualTo(maskShader));
|
|
Assert.That(orbitMaterial.HasProperty("_BodyMaskEnabled"), Is.True);
|
|
|
|
try
|
|
{
|
|
sourceCamera.orthographicSize = 2f;
|
|
foreach (bool charged in new[] { false, true })
|
|
{
|
|
world.CancelCyclone();
|
|
world.SetPaused(false);
|
|
world.DebugFillGauge();
|
|
Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True, "Each visual comparison needs a live Cyclone.");
|
|
world.SetPaused(true);
|
|
Vector2 lookAt = (world.Player.Position + world.CycloneCenter) * .5f;
|
|
Vector3 target = Defense25DActor.OnGround(lookAt, world.Settings.GroundY + .45f);
|
|
sourceCamera.transform.position = target - sourceCamera.transform.forward * 10f;
|
|
world.CycloneVisual.Height = .15f;
|
|
world.CycloneVisual.RadiusScale = 1f;
|
|
world.CycloneVisual.DecorationSpeed = 1f;
|
|
for (int frame = 0; frame < 8; frame++)
|
|
{
|
|
elapsedField.SetValue(world, frame * .05f);
|
|
for (int i = 0; i < 2; i++) yield return null;
|
|
string prefix = charged ? "charged" : "normal";
|
|
world.CycloneVisual.Ribbon = false;
|
|
yield return null;
|
|
Color32[] referencePixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-front-back-frame-{frame:00}");
|
|
world.CycloneVisual.Ribbon = true;
|
|
yield return null;
|
|
Color32[] orbitPixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-orbit-frame-{frame:00}");
|
|
Assert.That(CountCyclonePixels(orbitPixels), Is.GreaterThan(100), "Orbit curves must contribute visible mint pixels.");
|
|
Assert.That(DifferentPixels(referencePixels, orbitPixels), Is.GreaterThan(100), "Both captures must reflect their selected renderer.");
|
|
}
|
|
int restoredBodyPixels = 0;
|
|
var playFrame = typeof(Defense25DSprite).GetMethod("StartTimedSequence",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
Assert.That(playFrame, Is.Not.Null);
|
|
foreach (bool walking in new[] { false, true })
|
|
foreach (bool flip in new[] { false, true })
|
|
{
|
|
Sprite[] motion = walking ? world.Settings.PlayerArt.Walk : world.Settings.PlayerArt.Idle;
|
|
Assert.That(motion.Length, Is.GreaterThan(0));
|
|
playFrame.Invoke(world.Player.Visual, new object[] {
|
|
new[] { motion[walking ? Mathf.Min(1, motion.Length - 1) : 0] }, 1f, walking ? "Walk" : "Idle" });
|
|
world.Player.Facing = flip ? Vector2.left : Vector2.right;
|
|
elapsedField.SetValue(world, .1f);
|
|
string label = $"mask-{(charged ? "charged" : "normal")}-{(walking ? "walk" : "idle")}-{(flip ? "left" : "right")}";
|
|
world.CycloneVisual.ProtectBody = false;
|
|
yield return null;
|
|
yield return null;
|
|
Color32[] unmasked = CaptureCamera(sourceCamera, label + "-off");
|
|
world.CycloneVisual.ProtectBody = true;
|
|
yield return null;
|
|
Color32[] masked = CaptureCamera(sourceCamera, label + "-on");
|
|
world.CycloneVisual.SetVisible(false);
|
|
yield return null;
|
|
Color32[] baseline = CaptureCamera(sourceCamera, label + "-hidden");
|
|
world.CycloneVisual.SetVisible(true);
|
|
Assert.That(bodyRenderer.flipX, Is.EqualTo(flip));
|
|
Assert.That(orbitMaterial.GetTexture("_BodyTex"), Is.EqualTo(bodyRenderer.sprite.texture));
|
|
restoredBodyPixels += AssertCycloneBodyAndFeetPixels(sourceCamera, bodyRenderer,
|
|
world.Player.Visual.FootPosition, baseline, unmasked, masked, label);
|
|
}
|
|
Assert.That(restoredBodyPixels, Is.GreaterThan(0), "The mask must remove an actual body overlap in both Cyclone variants.");
|
|
world.Player.Visual.ResetVisual();
|
|
world.Player.Facing = Vector2.right;
|
|
yield return null;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (sourceCamera != null)
|
|
{
|
|
sourceCamera.transform.SetPositionAndRotation(originalPosition, originalRotation);
|
|
sourceCamera.orthographicSize = originalSize;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static int AssertCycloneBodyAndFeetPixels(Camera camera, SpriteRenderer body,
|
|
Vector3 foot, Color32[] baseline, Color32[] unmasked, Color32[] masked, string label)
|
|
{
|
|
Sprite sprite = body.sprite;
|
|
// Read alpha through a temporary GPU copy; production sprite importers
|
|
// deliberately keep CPU readability disabled.
|
|
Texture2D texture = new(sprite.texture.width, sprite.texture.height, TextureFormat.RGBA32, false, true);
|
|
RenderTexture alphaTarget = RenderTexture.GetTemporary(texture.width, texture.height, 0,
|
|
RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
|
|
RenderTexture previousTarget = RenderTexture.active;
|
|
Graphics.Blit(sprite.texture, alphaTarget);
|
|
RenderTexture.active = alphaTarget;
|
|
texture.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
|
|
texture.Apply();
|
|
RenderTexture.active = previousTarget;
|
|
RenderTexture.ReleaseTemporary(alphaTarget);
|
|
Rect rect = sprite.textureRect;
|
|
Vector2 pivot = sprite.pivot - sprite.textureRectOffset;
|
|
Vector3 footLocal = body.transform.InverseTransformPoint(foot);
|
|
float minY = footLocal.y + Defense25DComparisonSettings.CycloneBodyMaskFootClearancePixels / sprite.pixelsPerUnit;
|
|
Matrix4x4 inverse = body.transform.worldToLocalMatrix;
|
|
float depth = camera.WorldToScreenPoint(body.transform.position).z;
|
|
Vector3 footScreen = camera.WorldToScreenPoint(foot);
|
|
float cutoffY = camera.WorldToScreenPoint(body.transform.TransformPoint(new Vector3(footLocal.x, minY, 0))).y;
|
|
int bodyPixels = 0, bodyMismatch = 0, restored = 0, feetPixels = 0, outsidePixels = 0;
|
|
for (int y = 0; y < 360; y++)
|
|
for (int x = 0; x < 640; x++)
|
|
{
|
|
int index = y * 640 + x;
|
|
Vector3 local = inverse.MultiplyPoint3x4(camera.ScreenToWorldPoint(new Vector3(x + .5f, y + .5f, depth)));
|
|
float px = local.x * (body.flipX ? -1f : 1f) * sprite.pixelsPerUnit + pivot.x;
|
|
float py = local.y * (body.flipY ? -1f : 1f) * sprite.pixelsPerUnit + pivot.y;
|
|
bool protectedBody = false;
|
|
if (local.y >= minY && px >= 1 && py >= 1 && px < rect.width - 1 && py < rect.height - 1)
|
|
{
|
|
int tx = Mathf.FloorToInt(rect.x + px), ty = Mathf.FloorToInt(rect.y + py);
|
|
// Check interior opaque pixels; exclude the rasterized silhouette boundary.
|
|
protectedBody = texture.GetPixel(tx, ty).a >= .4f
|
|
&& texture.GetPixel(tx - 1, ty).a >= .4f && texture.GetPixel(tx + 1, ty).a >= .4f
|
|
&& texture.GetPixel(tx, ty - 1).a >= .4f && texture.GetPixel(tx, ty + 1).a >= .4f;
|
|
}
|
|
if (protectedBody)
|
|
{
|
|
bodyPixels++;
|
|
if (!masked[index].Equals(baseline[index])) bodyMismatch++;
|
|
if (!unmasked[index].Equals(baseline[index]) && masked[index].Equals(baseline[index])) restored++;
|
|
}
|
|
else if (!masked[index].Equals(baseline[index])) outsidePixels++;
|
|
if (y < cutoffY && y > footScreen.y - 32 && Mathf.Abs(x - footScreen.x) < 48
|
|
&& !masked[index].Equals(baseline[index])) feetPixels++;
|
|
}
|
|
Object.Destroy(texture);
|
|
Assert.That(bodyPixels, Is.GreaterThan(20), label + " must sample the opaque torso/head.");
|
|
Assert.That(bodyMismatch, Is.Zero, label + " protected body must match effect-hidden pixels.");
|
|
Assert.That(feetPixels, Is.GreaterThan(10), label + " foreground feet arcs must remain visible.");
|
|
Assert.That(outsidePixels, Is.GreaterThan(100), label + " outside orbit must remain visible.");
|
|
Debug.Log($"CYCLONE_BODY_MASK {label} body={bodyPixels} mismatch={bodyMismatch} restored={restored} feet={feetPixels} outside={outsidePixels}");
|
|
return restored;
|
|
}
|
|
|
|
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 Color32[] CaptureCamera(Camera camera, string label)
|
|
{
|
|
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
|
|
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
|
|
Directory.CreateDirectory(directory);
|
|
RenderTexture target = new(640, 360, 24, RenderTextureFormat.ARGB32);
|
|
RenderTexture previous = RenderTexture.active;
|
|
RenderTexture originalTarget = camera.targetTexture;
|
|
camera.targetTexture = target;
|
|
camera.Render();
|
|
RenderTexture.active = target;
|
|
Texture2D image = new(640, 360, TextureFormat.RGB24, false);
|
|
image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
|
image.Apply();
|
|
Color32[] pixels = image.GetPixels32();
|
|
File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG());
|
|
RenderTexture.active = previous;
|
|
camera.targetTexture = originalTarget;
|
|
target.Release();
|
|
Object.Destroy(target);
|
|
Object.Destroy(image);
|
|
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;
|
|
}
|
|
}
|
|
}
|