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(SettingsPath)); Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first."); settings.Enemy = AssetDatabase.LoadAssetAtPath( "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(); 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() .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(); 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(); 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(); 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 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(); 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(); 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(); 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(), wall.transform.position, wall.transform.rotation, out _, out _), Is.False); Assert.That(world.GetComponentsInChildren(true), Is.Empty); Assert.That(world.GetComponentsInChildren(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().Configure(world, ComparisonActorKind.Enemy); } GameObject wall = Wall(new Vector2(1.2f, 0f), new Vector3(.2f, 1.5f, 6f)); Collider wallCollider = wall.GetComponent(); 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().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(); 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(); 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(); 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(); 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().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(); 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().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(); 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(); 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( "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( "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; } } }