using System.Collections; using System.IO; using System.Linq; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Enemies; using NUnit.Framework; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DComparisonShowcasePlayModeTests { private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; private Defense25DComparison world; private float previousTimeScale; [UnitySetUp] public IEnumerator SetUp() { previousTimeScale = Time.timeScale; Time.timeScale = 1f; yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, new LoadSceneParameters(LoadSceneMode.Single)); world = Object.FindAnyObjectByType(); Assert.That(world, Is.Not.Null); yield return null; world.AutoSpawn = false; world.ReadInput = false; CompleteStartupArtifactSelection(world); yield return null; } [UnityTearDown] public IEnumerator TearDown() { Time.timeScale = previousTimeScale; yield return null; } [UnityTest] public IEnumerator ShowcaseUsesSixRealTowerFamiliesAndStationarySkeletonsThenRestoresTheRun() { Defense25DTowerManager runManager = world.TowerManager; runManager.AddCurrency(1000); DefenseConstants tuning = GameplayConstants.Current.Defense; Vector2 directPad = tuning.GetBuildPadPosition(0); world.Player.ResetAt(directPad); Physics.SyncTransforms(); Assert.That(runManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); Defense25DTower runDirect = runManager.GetTowerAtPad(0); Vector2 summonPad = tuning.GetBuildPadPosition(1); world.Player.ResetAt(summonPad); Physics.SyncTransforms(); Assert.That(runManager.TryBuildTowerAtPad(1, DefenseTowerKind.Summon), Is.True); Defense25DTower runSummonTower = runManager.GetTowerAtPad(1); Defense25DSummon runSummon = world.Summons.First(summon => summon.Owner == runSummonTower); Assert.That(runSummon, Is.Not.Null); runSummon.Actor.ResetAt(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon)); Defense25DEnemy runEnemy = null; for (int attempt = 0; attempt < GameplayConstants.Current.Defense.PortalEnemyCount * 2; attempt++) { Defense25DEnemy candidate = world.SpawnNextEnemy(); if (candidate == null) break; if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate)) { runEnemy = candidate; break; } } Assert.That(runEnemy, Is.Not.Null, "A non-immune lane must be available within the configured two-lane supply."); runEnemy.Actor.ResetAt(runDirect.Position + Vector2.right * .7f); Physics.SyncTransforms(); runDirect.StepParityAttacks(0f); Transform runProjectile = world.transform.Find("Tower Projectile"); Assert.That(runProjectile, Is.Not.Null, "The existing run must have a live tower projectile before showcase entry."); DefenseSpriteSequence projectileSequence = runProjectile.GetComponentInChildren(true); Assert.That(projectileSequence, Is.Not.Null); Assert.That(projectileSequence.IsFinished, Is.False); Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton")); Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null); Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0)); Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack")); int runCurrency = runManager.ConstructionCurrency; int supplied = world.Ledger.SuppliedCount; int resolved = world.Ledger.ResolvedCount; int kills = world.KillCurrency; float baseHealth = world.BaseHealth; float gameTime = world.GameTime; Vector2 playerPosition = new(-4.5f, 0f); world.Player.ResetAt(playerPosition); Physics.SyncTransforms(); DefenseTowerKind[] kinds = DefenseTowerHitTestLayout.KindsByPad; world.BindValidationShowcase(world.EnterTowerShowcase, world.ExitTowerShowcase); Assert.That(world.EnterValidationShowcase(), Is.True); Assert.That(world.IsTowerShowcaseSimulationActive, Is.True); Assert.That(world.TowerManager, Is.Not.SameAs(runManager)); Assert.That(runManager.IsSuspended, Is.True); Assert.That(runDirect.GetComponentInChildren(true).enabled, Is.False, "The parked run tower must not render over the showcase tower on the shared pad."); Assert.That(runSummon.gameObject.activeSelf, Is.False, "Parked summon colliders and movement must not participate in the showcase."); Assert.That(runProjectile.gameObject.activeInHierarchy, Is.False, "A parked flight and its sprite sequence must not advance or expire during showcase time."); Assert.That(runEnemy.gameObject.activeSelf, Is.False, "Normal route enemies must be suspended while the isolated showcase runs."); Assert.That(world.TowerShowcaseDummies.Count, Is.EqualTo(kinds.Length)); Assert.That(world.TowerManager.Towers.Count, Is.EqualTo(kinds.Length)); for (int i = 0; i < kinds.Length; i++) { Assert.That(world.TowerManager.GetTowerAtPad(i).Kind, Is.EqualTo(kinds[i])); Assert.That(world.TowerManager.GetTowerAtPad(i).Tier, Is.EqualTo(1)); Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kinds[i]); Assert.That(dummy, Is.Not.Null); Assert.That(dummy.IsShowcaseDummy, Is.True); Assert.That(dummy.IsAvailable, Is.True); Assert.That(dummy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_Idle")); Assert.That(world.IsTowerShowcaseTarget(kinds[i], dummy), Is.True); Assert.That(world.IsTowerShowcaseTarget(kinds[(i + 1) % kinds.Length], dummy), Is.False); Assert.That(world.GetTowerTargets(kinds[i]).Where(target => target.IsShowcaseDummy), Is.EquivalentTo(new[] { dummy }), "Each family may query only its assigned showcase target."); } var dummyPositions = kinds.ToDictionary(kind => kind, kind => world.GetTowerShowcaseDummy(kind).Actor.Position); float evidenceDeadline = Time.realtimeSinceStartup + 20f; while (Time.realtimeSinceStartup < evidenceDeadline && kinds.Where(kind => kind != DefenseTowerKind.Empowering) .Any(kind => world.GetTowerShowcaseHitCount(kind) == 0)) yield return new WaitForFixedUpdate(); for (int i = 0; i < 150; i++) yield return new WaitForFixedUpdate(); Camera camera = world.View.Camera; camera.Render(); Color32[] rendered = ReadPixels(world.View.Texture); foreach (DefenseTowerKind kind in kinds) { Defense25DTower tower = world.TowerManager.TowersInConstructionOrder .First(item => item.Kind == kind); SpriteRenderer towerBody = tower.transform.Find("Tower billboard").GetComponent(); Assert.That(towerBody.sprite, Is.Not.Null, $"{kind} must render its authored tower body sprite."); Assert.That(RendererContribution(camera, rendered, towerBody), Is.GreaterThan(0), $"{kind} tower body must contribute pixels through the saved scene's renderer."); Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kind); Assert.That(RendererContribution(camera, rendered, dummy.Actor.Visual.Renderer), Is.GreaterThan(0), $"The {kind} assigned Skeleton must contribute full-body sprite pixels."); } Assert.That(RendererContribution(camera, rendered, world.Player.Visual.Renderer), Is.GreaterThan(0), "The saved-scene player sprite must remain visible in the showcase capture."); CaptureTowerShowcase(); foreach (DefenseTowerKind kind in kinds.Where(kind => kind != DefenseTowerKind.Empowering)) { Defense25DTower tower = world.TowerManager.TowersInConstructionOrder .First(item => item.Kind == kind); string summonState = kind == DefenseTowerKind.Summon ? string.Join("; ", world.Summons.Where(item => item.Owner == tower).Select(item => $"slot={item.SlotIndex} pos={item.Actor.Position} alive={item.Actor.IsAlive} " + $"attack={item.IsAttacking} attacks={item.AttackEvents} moving={item.Actor.LastMove} " + $"knockback={item.Actor.HasKnockback} elapsed={GetPrivateFloat(item, "attackElapsed"):0.000} " + $"next={GetPrivateFloat(item, "nextAttack"):0.000} targetGap=" + (tower.CommandTarget == null ? "none" : item.Actor.GapTo(tower.CommandTarget.Actor).ToString("0.000")))) : string.Empty; Assert.That(world.GetTowerShowcaseHitCount(kind), Is.GreaterThan(0), $"No actual attack evidence for {kind}. command={tower.CommandTarget?.name ?? "none"}; " + $"targets={world.GetTowerTargets(kind).Count()} gameTime={world.GameTime:0.000} " + $"running={world.IsRunning} paused={world.Paused} operational={world.TowersOperational} " + $"towerEnabled={tower.isActiveAndEnabled} summonCount={tower.SummonCount}/{tower.TargetSummonCount} {summonState}"); } Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Direct), Is.GreaterThan(0f), "The pad-4 Empowering tower must support the pad-0 Direct tower within the authored radius."); Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Summon), Is.EqualTo(0f), "The remote pad-1 Summon tower is outside the authored support radius."); foreach (DefenseTowerKind kind in kinds) Assert.That(world.GetTowerShowcaseDummy(kind).Actor.Position, Is.EqualTo(dummyPositions[kind]), $"The {kind} Skeleton target must remain stationary."); Defense25DEnemy respawnTarget = world.GetTowerShowcaseDummy(DefenseTowerKind.Direct); float aliveDeadline = Time.realtimeSinceStartup + 8f; while (!respawnTarget.IsAvailable && Time.realtimeSinceStartup < aliveDeadline) yield return new WaitForFixedUpdate(); Assert.That(respawnTarget.IsAvailable, Is.True); Defense25DTower directTower = world.TowerManager.GetTowerAtPad(0); MethodInfo applyDamage = typeof(Defense25DTower).GetMethod("ApplyDamage", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(applyDamage, Is.Not.Null); int popupCount = world.ActiveTowerDamagePopupCount; Assert.That(applyDamage.Invoke(directTower, new object[] { respawnTarget, respawnTarget.Actor.MaxHealth + 1f }), Is.EqualTo(true)); Assert.That(respawnTarget.Actor.CurrentHealth, Is.EqualTo(respawnTarget.Actor.MaxHealth), "A showcase dummy respawns synchronously at full health after a lethal tower hit."); Assert.That(world.ActiveTowerDamagePopupCount, Is.GreaterThan(popupCount), "The lethal hit's actual damage popup must be recorded before immediate dummy respawn."); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied)); Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved)); Assert.That(world.KillCurrency, Is.EqualTo(kills)); Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency)); Assert.That(respawnTarget.IsAvailable, Is.True, "A dead tower-test dummy must be replaced immediately, not after the summon-unit timer."); Assert.That(respawnTarget.Actor.Position, Is.EqualTo(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Direct))); Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved)); Assert.That(world.KillCurrency, Is.EqualTo(kills)); Assert.That(world.ExitValidationShowcase(), Is.True); Assert.That(world.IsTowerShowcaseSimulationActive, Is.False); Assert.That(world.TowerManager, Is.SameAs(runManager)); Assert.That(runManager.IsSuspended, Is.False); Assert.That(runDirect.GetComponentInChildren(true).enabled, Is.True); Assert.That(runSummon.gameObject.activeSelf, Is.True); Assert.That(runSummon.Actor.BodyCollider.enabled, Is.True); Assert.That(runSummon.Actor.Visual.Renderer.enabled, Is.True); Assert.That(runProjectile.gameObject.activeInHierarchy, Is.True); Assert.That(projectileSequence.IsFinished, Is.False, "The existing projectile must resume with its remaining flight after showcase exit."); Assert.That(runEnemy.gameObject.activeSelf, Is.True, "The previously active route Skeleton must resume after leaving the showcase."); Assert.That(runEnemy.Actor.BodyCollider.enabled, Is.True, "The resumed route Skeleton restores its collision footprint."); Assert.That(runEnemy.Actor.Visual.Renderer.enabled, Is.True, "The resumed route Skeleton restores its billboard."); Assert.That(runManager.ConstructionCurrency, Is.EqualTo(runCurrency)); Assert.That(world.Player.Position, Is.EqualTo(playerPosition)); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied)); Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved)); Assert.That(world.KillCurrency, Is.EqualTo(kills)); Assert.That(world.BaseHealth, Is.EqualTo(baseHealth)); Assert.That(world.GameTime, Is.EqualTo(gameTime).Within(.001f)); Assert.That(world.IsRunning, Is.True); } private static float GetPrivateFloat(Defense25DSummon summon, string field) => (float)typeof(Defense25DSummon).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(summon); private static int RendererContribution(Camera camera, Color32[] visible, Renderer renderer) { Vector3 screen = camera.WorldToScreenPoint(renderer.bounds.center); Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane)); bool wasEnabled = renderer.enabled; try { renderer.enabled = false; camera.Render(); Color32[] hidden = ReadPixels(camera.targetTexture as RenderTexture); return DifferentPixelsNear(visible, hidden, screen, 28); } finally { renderer.enabled = wasEnabled; camera.Render(); } } private static int DifferentPixelsNear(Color32[] a, Color32[] b, Vector3 center, int radius) { int count = 0, cx = Mathf.RoundToInt(center.x), cy = Mathf.RoundToInt(center.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; if (!a[py * Defense25DView.Width + px].Equals(b[py * Defense25DView.Width + px])) count++; } return count; } private static Color32[] ReadPixels(RenderTexture texture) { RenderTexture previous = RenderTexture.active; RenderTexture.active = texture; Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false); image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0); image.Apply(); Color32[] pixels = image.GetPixels32(); RenderTexture.active = previous; Object.DestroyImmediate(image); return pixels; } private static void CompleteStartupArtifactSelection(Defense25DComparison comparison) { var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact", BindingFlags.Instance | 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 showcase fixture must complete the authored startup artifact choices before entering the run."); } private void CaptureTowerShowcase() { string directory = System.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(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false); image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0); image.Apply(); RenderTexture.active = previous; File.WriteAllBytes(Path.Combine(directory, "20-tower-showcase-six-families-640x360.png"), image.EncodeToPNG()); Object.Destroy(image); } } }