Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DFacilityVisualPlayModeTests.cs
T
2026-09-28 11:07:17 +09:00

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;
}
}
}