300 lines
18 KiB
C#
300 lines
18 KiB
C#
using System.Collections;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using BumpCombat.Combat;
|
|
using BumpCombat.Constants;
|
|
using BumpCombat.Defense;
|
|
using BumpCombat.Defense.Comparison;
|
|
using NUnit.Framework;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace BumpCombat.Tests
|
|
{
|
|
public sealed class Defense25DFacilityVisualPlayModeTests
|
|
{
|
|
private const string ComparisonSettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
|
private const string FacilitySettingsPath = "Assets/_Project/Constants/Defense25DComparison/FacilityVisualConstants.asset";
|
|
private Defense25DComparison world;
|
|
private Defense25DComparison integratedWorld;
|
|
private Defense25DComparisonSettings comparisonSettings;
|
|
private Defense25DRunDefinition runDefinition;
|
|
private Defense25DFacilityVisualConstants facilitySettings;
|
|
private Defense25DFacilityVisuals visuals;
|
|
private float previousTimeScale;
|
|
|
|
[UnitySetUp]
|
|
public IEnumerator SetUp()
|
|
{
|
|
previousTimeScale = Time.timeScale;
|
|
Time.timeScale = 1f;
|
|
comparisonSettings = Object.Instantiate(
|
|
AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(ComparisonSettingsPath));
|
|
facilitySettings = AssetDatabase.LoadAssetAtPath<Defense25DFacilityVisualConstants>(FacilitySettingsPath);
|
|
Assert.That(comparisonSettings, Is.Not.Null);
|
|
Assert.That(facilitySettings, Is.Not.Null);
|
|
|
|
world = new GameObject("Facility visual test world").AddComponent<Defense25DComparison>();
|
|
world.Initialize(comparisonSettings);
|
|
world.AutoSpawn = false;
|
|
world.ReadInput = false;
|
|
visuals = new GameObject("Facility visuals under test").AddComponent<Defense25DFacilityVisuals>();
|
|
Assert.That(visuals.Bind(world, facilitySettings, 17, out string error), Is.True, error);
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTearDown]
|
|
public IEnumerator TearDown()
|
|
{
|
|
Time.timeScale = 1f;
|
|
if (visuals != null) visuals.Clear();
|
|
if (visuals != null) Object.Destroy(visuals.gameObject);
|
|
if (integratedWorld != null) Object.Destroy(integratedWorld.gameObject);
|
|
if (world != null) Object.Destroy(world.gameObject);
|
|
if (comparisonSettings != null) Object.Destroy(comparisonSettings);
|
|
if (runDefinition != null) Object.Destroy(runDefinition);
|
|
Time.timeScale = previousTimeScale;
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator PlaybackTimingCanScaleAfterPlayAndPlayResetsToPausedClock()
|
|
{
|
|
const string key = "Effects/Lightning/ImpactWave";
|
|
Sprite[] frames = DefenseTowerArt.Load(key);
|
|
Assert.That(frames, Is.Not.Empty);
|
|
Time.timeScale = 0f;
|
|
|
|
var spedUp = CreateSequence("Unscaled sequence");
|
|
spedUp.Sequence.Play(spedUp.Renderer, key, frames, false);
|
|
spedUp.Sequence.SetPlaybackTiming(.5f, useUnscaledTime: true);
|
|
yield return new WaitForSecondsRealtime(.22f);
|
|
Assert.That(spedUp.Sequence.IsFinished, Is.True,
|
|
"The 0.30 second source strip must finish in 0.15 unscaled seconds at durationScale .5.");
|
|
|
|
spedUp.Sequence.Play(spedUp.Renderer, key, frames, false);
|
|
yield return new WaitForSecondsRealtime(.22f);
|
|
Assert.That(spedUp.Sequence.IsFinished, Is.False,
|
|
"A later Play on a completed sequence must restore its default pause-aware clock and unit duration scale.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator SpawnGateClearAndUpgradeReplacementUseOnlyDepthTestedSpriteLayers()
|
|
{
|
|
Vector3 anchor = new(2f, comparisonSettings.GroundY, -3f);
|
|
visuals.PlayPortalSpawn(4, anchor);
|
|
visuals.PlayPortalSpawn(4, anchor);
|
|
visuals.PlayPortalSpawn(5, anchor);
|
|
yield return null;
|
|
|
|
Assert.That(ActiveRootCount(), Is.EqualTo(2), "A portal event is once per portal ID and generation.");
|
|
Assert.That(visuals.GetComponentsInChildren<Collider>(true), Is.Empty,
|
|
"Facility art cannot add hitboxes or gameplay physics.");
|
|
SpriteRenderer[] renderers = visuals.GetComponentsInChildren<SpriteRenderer>(true);
|
|
Assert.That(renderers, Is.Not.Empty);
|
|
Assert.That(renderers.All(renderer => renderer.sharedMaterial == comparisonSettings.CutoutMaterial), Is.True,
|
|
"Facility layers must use the world's existing depth-tested cutout material.");
|
|
Assert.That(renderers.All(renderer => renderer.color == Color.white), Is.True,
|
|
"The original sprites retain their authored palette without a tint override.");
|
|
Transform portalRoot = ActiveRoots().First(root => root.name.Contains("Portal Spawn")).transform;
|
|
Transform groundRing = portalRoot.Find("Portal Spawn 4 Ground");
|
|
Transform verticalStrip = portalRoot.Find("Portal Spawn 4 Vertical");
|
|
Assert.That(groundRing.position.x, Is.EqualTo(anchor.x).Within(.001f));
|
|
Assert.That(groundRing.position.z, Is.EqualTo(anchor.z).Within(.001f));
|
|
Assert.That(groundRing.position.y, Is.EqualTo(anchor.y + facilitySettings.PortalSpawn.GroundYOffset).Within(.001f));
|
|
Assert.That(verticalStrip.position.y, Is.EqualTo(anchor.y + facilitySettings.PortalSpawn.VerticalHeight).Within(.001f));
|
|
|
|
visuals.Clear();
|
|
Assert.That(ActiveRootCount(), Is.Zero);
|
|
Assert.That(visuals.Bind(world, facilitySettings, 18, out string error), Is.True, error);
|
|
visuals.PlayPortalSpawn(4, anchor);
|
|
Assert.That(ActiveRootCount(), Is.EqualTo(1), "Clear and rebind start a fresh generation gate.");
|
|
|
|
visuals.Clear();
|
|
Assert.That(visuals.Bind(world, facilitySettings, 19, out error), Is.True, error);
|
|
visuals.PlayTowerUpgrade(2, 2, anchor);
|
|
GameObject priorUpgrade = ActiveRoots().Single();
|
|
visuals.PlayTowerUpgrade(2, 3, anchor);
|
|
GameObject replacement = ActiveRoots().Single();
|
|
Assert.That(replacement, Is.Not.SameAs(priorUpgrade),
|
|
"A successful upgrade replaces that pad's previous in-flight display instead of stacking it.");
|
|
Assert.That(replacement.name, Does.Contain("Tower Upgrade"));
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator NormalEffectsPauseButTerminalHomeBurstCompletesOnUnscaledTime()
|
|
{
|
|
Vector3 anchor = new(1f, comparisonSettings.GroundY, 2f);
|
|
Time.timeScale = 0f;
|
|
visuals.PlayPortalSpawn(1, anchor);
|
|
visuals.PlayHomeBurst(anchor);
|
|
visuals.PlayPortalBurst(10, anchor);
|
|
|
|
yield return new WaitForSecondsRealtime(.95f);
|
|
Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Spawn")), Is.True,
|
|
"The normal portal opening effect follows the paused game clock.");
|
|
Assert.That(ActiveRoots().Any(root => root.name.Contains("Home Burst")), Is.False,
|
|
"The terminal home burst finishes while gameplay is paused.");
|
|
Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Burst")), Is.False,
|
|
"The terminal portal burst also finishes while gameplay is paused.");
|
|
|
|
Time.timeScale = 1f;
|
|
yield return new WaitForSeconds(.7f);
|
|
Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Spawn")), Is.False);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator AllFacilityEventApisUseTheirAuthoredOncePerRunRules()
|
|
{
|
|
Assert.That(facilitySettings.PortalSpawn.TargetDurationSeconds, Is.EqualTo(.6f).Within(.001f));
|
|
Assert.That(facilitySettings.PortalBurst.TargetDurationSeconds, Is.EqualTo(.5f).Within(.001f));
|
|
Assert.That(facilitySettings.HomeSpawn.TargetDurationSeconds, Is.EqualTo(.8f).Within(.001f));
|
|
Assert.That(facilitySettings.HomeBurst.TargetDurationSeconds, Is.EqualTo(.8f).Within(.001f));
|
|
Assert.That(facilitySettings.TowerPadSpawn.TargetDurationSeconds, Is.EqualTo(.4f).Within(.001f));
|
|
Assert.That(facilitySettings.TowerUpgrade.TargetDurationSeconds, Is.EqualTo(.45f).Within(.001f));
|
|
Vector3 anchor = new(-2f, comparisonSettings.GroundY, 1f);
|
|
visuals.PlayPortalBurst(3, anchor);
|
|
visuals.PlayPortalBurst(3, anchor);
|
|
visuals.PlayPortalBurst(4, anchor);
|
|
visuals.PlayHomeSpawn(anchor);
|
|
visuals.PlayHomeSpawn(anchor);
|
|
visuals.PlayPadSpawn(8, anchor);
|
|
visuals.PlayPadSpawn(8, anchor);
|
|
yield return null;
|
|
|
|
Assert.That(ActiveRootCount(), Is.EqualTo(4),
|
|
"Portal bursts are once per portal, while home and pad spawn are once per run and pad ID.");
|
|
Assert.That(ActiveRoots().Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2));
|
|
Assert.That(ActiveRoots().Count(root => root.name.Contains("Home Spawn")), Is.EqualTo(1));
|
|
Assert.That(ActiveRoots().Count(root => root.name.Contains("Tower Pad Spawn")), Is.EqualTo(1));
|
|
Assert.That(ActiveRoots().Single(root => root.name.Contains("Home Spawn")).transform.childCount,
|
|
Is.EqualTo(2), "Home spawn combines its authored ground ring and vertical strip.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ActualLongRunSetupPortalOpenTowerUpgradeAndVictoryDriveFacilityEffects()
|
|
{
|
|
Time.timeScale = 0f;
|
|
InitializeIntegratedLongRun();
|
|
Defense25DFacilityVisuals integratedVisuals = integratedWorld.GetComponent<Defense25DFacilityVisuals>();
|
|
Assert.That(integratedVisuals, Is.Not.Null);
|
|
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing));
|
|
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Home Spawn")), Is.EqualTo(1));
|
|
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Tower Pad Spawn")), Is.EqualTo(18));
|
|
|
|
integratedWorld.TowerManager.AddCurrency(10000);
|
|
Vector2 pad = integratedWorld.TowerManager.GetPadPosition(0);
|
|
integratedWorld.Player.ResetAt(pad);
|
|
Assert.That(integratedWorld.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
|
|
Assert.That(integratedWorld.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True);
|
|
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Tower Upgrade Pad 0 T2")), Is.True,
|
|
"The real tower manager success path must call the bound facility visual owner.");
|
|
|
|
integratedWorld.AdvanceRunClock(runDefinition.PreparationSeconds);
|
|
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Running),
|
|
"The 0.8 second setup effects cannot extend preparation.");
|
|
Assert.That(integratedWorld.WaveDirector.IsPortalOpen(0), Is.True);
|
|
Assert.That(integratedWorld.WaveDirector.IsPortalOpen(1), Is.True);
|
|
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Spawn")), Is.EqualTo(2),
|
|
"The actual portal-open timeline must start each opening effect once.");
|
|
|
|
integratedWorld.WaveDirector.AdvanceTo(18f, integratedWorld.SpawnScheduledEnemy);
|
|
Assert.That(integratedWorld.Enemies, Is.Not.Empty,
|
|
"The first real wave composition must spawn while the opening effects are still paused.");
|
|
Assert.That(integratedWorld.NotifyLongRunBossActivated(), Is.True);
|
|
Assert.That(integratedWorld.BeginLongRunClearing(), Is.True);
|
|
foreach (Defense25DEnemy enemy in integratedWorld.Enemies.ToArray())
|
|
integratedWorld.ResolveEnemy(enemy, DefenseEnemyResolution.Death);
|
|
|
|
for (int choice = 0; integratedWorld.Progression.HasPendingReward && choice < 32; choice++)
|
|
{
|
|
string rewardToken = integratedWorld.Progression.CurrentRewardToken;
|
|
Assert.That(integratedWorld.Progression.TryChooseReward(0, rewardToken), Is.True,
|
|
$"The integrated victory path must resolve pending reward {rewardToken} through its public choice API.");
|
|
}
|
|
Assert.That(integratedWorld.Progression.HasPendingReward, Is.False,
|
|
"All queued level-up choices must be completed before the victory gate can close.");
|
|
|
|
string pendingMissions = string.Join(",", integratedWorld.PersonalMissionStates
|
|
.Where(state => state.Status == Defense25DPersonalMissionStatus.RewardPending)
|
|
.Select(state => $"{state.MissionId}:{state.MissionInstanceId}"));
|
|
Assert.That(integratedWorld.RunCleared, Is.True,
|
|
$"Victory gate state: phase={integratedWorld.RunPhase}, remaining={integratedWorld.RemainingEnemies}, " +
|
|
$"supplied={integratedWorld.Ledger.SuppliedCount}, resolved={integratedWorld.Ledger.ResolvedCount}, " +
|
|
$"level={integratedWorld.Progression.Level}, xp={integratedWorld.Progression.CurrentExperience}/" +
|
|
$"{integratedWorld.Progression.RequiredExperience}, levelChoice={integratedWorld.Progression.HasPendingLevelChoice}, " +
|
|
$"rewardCount={integratedWorld.Progression.RewardChoices.PendingRewardCount}, " +
|
|
$"fieldRelic={integratedWorld.HasPendingFieldRelicReward}, dropChoice={integratedWorld.HasPendingArtifactDropChoice}, " +
|
|
$"missionRewards={pendingMissions}.");
|
|
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Victory));
|
|
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2));
|
|
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Home Burst")), Is.False,
|
|
"Victory bursts open portals but does not destroy the home.");
|
|
yield return new WaitForSecondsRealtime(.7f);
|
|
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Portal Burst")), Is.False,
|
|
"Victory portal bursts complete on the unscaled terminal clock.");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ActualBaseArrivalDefeatStartsHomeAndOpenPortalBurstsImmediately()
|
|
{
|
|
Time.timeScale = 0f;
|
|
InitializeIntegratedLongRun();
|
|
integratedWorld.AdvanceRunClock(runDefinition.PreparationSeconds);
|
|
integratedWorld.WaveDirector.AdvanceTo(18f, integratedWorld.SpawnScheduledEnemy);
|
|
Assert.That(integratedWorld.Enemies, Is.Not.Empty);
|
|
|
|
float arrivalDamage = GameplayConstants.Current.Defense.ArrivalBaseDamage;
|
|
FieldInfo baseHealth = typeof(Defense25DComparison).GetField(
|
|
"<BaseHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.That(baseHealth, Is.Not.Null, "The fixture sets a boundary HP to exercise one actual arrival terminal transition.");
|
|
baseHealth.SetValue(integratedWorld, arrivalDamage);
|
|
integratedWorld.ResolveEnemy(integratedWorld.Enemies[0], DefenseEnemyResolution.Arrival);
|
|
|
|
Defense25DFacilityVisuals integratedVisuals = integratedWorld.GetComponent<Defense25DFacilityVisuals>();
|
|
Assert.That(integratedWorld.RunFailed, Is.True);
|
|
Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat));
|
|
Assert.That(integratedWorld.BaseHealth, Is.Zero);
|
|
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Home Burst")), Is.EqualTo(1));
|
|
Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2));
|
|
|
|
yield return new WaitForSecondsRealtime(.9f);
|
|
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Home Burst")), Is.False,
|
|
"Defeat is immediate while the home burst finishes on its unscaled display clock.");
|
|
Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Portal Burst")), Is.False);
|
|
}
|
|
|
|
private (DefenseSpriteSequence Sequence, SpriteRenderer Renderer) CreateSequence(string name)
|
|
{
|
|
var gameObject = new GameObject(name);
|
|
SpriteRenderer renderer = gameObject.AddComponent<SpriteRenderer>();
|
|
DefenseSpriteSequence sequence = gameObject.AddComponent<DefenseSpriteSequence>();
|
|
return (sequence, renderer);
|
|
}
|
|
|
|
private int ActiveRootCount() => ActiveRoots().Length;
|
|
|
|
private GameObject[] ActiveRoots() => ActiveRoots(visuals);
|
|
|
|
private static GameObject[] ActiveRoots(Defense25DFacilityVisuals owner) => owner.transform.Cast<Transform>()
|
|
.Where(child => child.gameObject.activeInHierarchy)
|
|
.Select(child => child.gameObject)
|
|
.ToArray();
|
|
|
|
private void InitializeIntegratedLongRun()
|
|
{
|
|
runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(
|
|
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"));
|
|
Assert.That(runDefinition, Is.Not.Null);
|
|
integratedWorld = new GameObject("Facility integrated long run under test")
|
|
.AddComponent<Defense25DComparison>();
|
|
integratedWorld.InitializeLongRun(comparisonSettings, runDefinition, 23);
|
|
integratedWorld.AutoSpawn = false;
|
|
integratedWorld.ReadInput = false;
|
|
}
|
|
}
|
|
}
|