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

1383 lines
78 KiB
C#

using System;
using System.Collections;
using System.IO;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DComparisonPlayModeTests
{
private Defense25DComparison world;
private Defense25DComparisonSettings settings;
private float previousTimeScale;
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
[UnitySetUp]
public IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 5f;
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first.");
settings.Enemy = AssetDatabase.LoadAssetAtPath<BumpCombat.Enemies.EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack");
settings.EnemyRadius = .24f;
settings.EnemyColliderOffset = new Vector2(0f, -.28f);
settings.EnemyVisualScale = 1f;
settings.EnemyFootOffset = -.4f;
settings.EnemyBodyCenterCorrectionPixels = 0f;
world = new GameObject("Comparison under test").AddComponent<Defense25DComparison>();
world.Initialize(settings);
world.AutoSpawn = false;
world.ReadInput = false;
world.Player.ResetAt(Vector2.zero);
yield return new WaitForFixedUpdate();
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
Time.timeScale = previousTimeScale;
yield return null;
}
[UnityTest]
public IEnumerator ResonantCrystalRepeatExpiresWhileDeadAndDoesNotReturnOnRevive()
{
var relicTuning = GameplayConstants.Current.PassiveRelics;
float previousChance = relicTuning.ResonantCrystalChance;
float previousDelay = relicTuning.ResonantCrystalDelaySeconds;
try
{
relicTuning.ResonantCrystalChance = 1f;
relicTuning.ResonantCrystalDelaySeconds = .2f;
CompleteStartupArtifactSelection(world);
ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True);
Assert.That(world.Loadout.TryAcquireRelic(BumpCombat.Progression.PassiveRelicId.ResonantCrystal), Is.True);
world.Loadout.AddGauge(artifact.NormalGaugeCost);
Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(artifact.NormalGaugeCost));
world.RelicRuntime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false),
Vector3.zero, Vector3.right, world.Player.CurrentHealth);
Assert.That(world.RelicRuntime.IsRepeatPending, Is.True);
Assert.That(world.Player.TakeDamage(world.Player.MaxHealth * 10f, Vector2.left), Is.True);
Assert.That(world.Player.IsAlive, Is.False);
for (int i = 0; i < 30 && world.RelicRuntime.IsRepeatPending; i++)
yield return new WaitForFixedUpdate();
Assert.That(world.Player.IsAlive, Is.False, "The repeat deadline must pass before the five-second revive.");
Assert.That(world.RelicRuntime.IsRepeatPending, Is.False,
"A repeat whose delay expires at zero health is discarded, not deferred to revival.");
float reviveDeadline = world.GameTime + GameplayConstants.Current.Defense.PlayerReviveDelaySeconds + 1f;
float realtimeDeadline = Time.realtimeSinceStartup + 10f;
while (!world.Player.IsAlive && world.GameTime < reviveDeadline
&& Time.realtimeSinceStartup < realtimeDeadline)
yield return null;
Assert.That(world.Player.IsAlive, Is.True);
Assert.That(world.RelicRuntime.IsRepeatPending, Is.False);
}
finally
{
relicTuning.ResonantCrystalChance = previousChance;
relicTuning.ResonantCrystalDelaySeconds = previousDelay;
}
}
[UnityTest]
public IEnumerator ArtifactHitRelicStatusTargetsResolveTheActualDefenseEnemy()
{
var relicTuning = GameplayConstants.Current.PassiveRelics;
float previousChance = relicTuning.EmberWickChance;
try
{
relicTuning.EmberWickChance = 1f;
CompleteStartupArtifactSelection(world);
ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone);
Assert.That(world.Loadout.TryAddArtifact(cyclone), Is.True);
Assert.That(world.Loadout.TryAcquireRelic(
BumpCombat.Progression.PassiveRelicId.EmberWick), Is.True);
world.Loadout.AddGauge(cyclone.NormalGaugeCost);
Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(cyclone.NormalGaugeCost));
Defense25DEnemy enemy = world.Enemies[0];
Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false,
settings.PlayerVisualScale);
enemy.Spawn(center);
Physics.SyncTransforms();
Assert.That(world.Loadout.TryUse(false), Is.True);
world.ArtifactExecutor.Step(cyclone.NormalDuration + .05f);
Assert.That(enemy.IgniteRemaining, Is.GreaterThan(0f),
"An artifact-originated relic hit must resolve the enemy's live status target.");
}
finally
{
relicTuning.EmberWickChance = previousChance;
}
yield return null;
}
[UnityTest]
public IEnumerator BumpTriggeredResonanceReplayDoesNotInvalidateWorldQueryTargets()
{
var relicTuning = GameplayConstants.Current.PassiveRelics;
float previousChance = relicTuning.ResonanceEchoChance;
float previousCooldown = relicTuning.ResonanceEchoCooldownSeconds;
try
{
relicTuning.ResonanceEchoChance = 1f;
relicTuning.ResonanceEchoCooldownSeconds = 0f;
ActiveArtifactDefinition chain = world.Loadout.FindArtifact(ActiveArtifactEffect.ChainLightning);
Assert.That(world.Loadout.TryAddArtifact(chain), Is.True);
Assert.That(world.Loadout.TryAcquireRelic(
BumpCombat.Progression.PassiveRelicId.ResonanceCore), Is.True);
Defense25DEnemy first = world.Enemies[0];
Defense25DEnemy second = world.Enemies[1];
first.Spawn(new Vector2(.5f, .28f));
second.Spawn(new Vector2(1f, .28f));
Physics.SyncTransforms();
Assert.That(world.Player.GapTo(second.Actor),
Is.GreaterThan(settings.ContactSensorMargin),
"The second enemy must be reached by the replay, not by the ordinary bump.");
bool ordinaryBumpResolved = false;
bool echoHitSecondEnemy = false;
void CaptureHit(CombatHitResult hit)
{
ordinaryBumpResolved |= hit.Target == first.gameObject && hit.IsOrdinaryBump;
echoHitSecondEnemy |= hit.Target == second.gameObject
&& hit.SourceId == Defense25DRelicRuntime.EchoSourceId;
}
CombatEvents.OnValidHit += CaptureHit;
try
{
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
Assert.That(world.BumpEvents, Is.GreaterThan(0), "The initial ordinary bump must resolve.");
Assert.That(ordinaryBumpResolved, Is.True);
Assert.That(first.Actor.DamageEvents, Is.GreaterThan(0));
Assert.That(second.Actor.DamageEvents, Is.GreaterThan(0));
Assert.That(echoHitSecondEnemy, Is.True,
"The synchronous echo chain must query and hit the second enemy without invalidating the bump snapshot.");
}
finally
{
CombatEvents.OnValidHit -= CaptureHit;
}
}
finally
{
relicTuning.ResonanceEchoChance = previousChance;
relicTuning.ResonanceEchoCooldownSeconds = previousCooldown;
}
yield return null;
}
private GameObject Wall(Vector2 position, Vector3 size)
{
GameObject wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
wall.transform.SetParent(world.transform);
wall.layer = Defense25DComparisonSettings.TerrainLayer;
wall.transform.position = Defense25DActor.OnGround(position, settings.GroundY + size.y * .5f);
wall.transform.localScale = size;
Physics.SyncTransforms();
return wall;
}
private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack)
{
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath(
$"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
.OfType<Sprite>()
.OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0)
.ToArray();
return new ComparisonSpriteFrames
{
Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack),
Hurt = Read("Hurt"), Death = Read("Death")
};
}
[UnityTest]
public IEnumerator SavedSceneStartsFromStoppedTimeAndReadsWasd()
{
Object.Destroy(world.gameObject);
yield return null;
// The original project's TimeManager currently starts at zero. Do not
// hide that startup condition with the fixture's accelerated clock.
Time.timeScale = 0f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
Assert.That(world, Is.Not.Null);
CompleteStartupArtifactSelection(world);
Assert.That(Time.timeScale, Is.EqualTo(1f), "The independent scene must start its own clock even when the project starts paused.");
Assert.That(world.ReadInput && world.AutoSpawn && world.IsRunning && !world.Paused, Is.True);
float started = world.GameTime;
yield return new WaitForSecondsRealtime(.15f);
Assert.That(world.GameTime, Is.GreaterThan(started));
Assert.That(world.Ledger.SuppliedCount, Is.GreaterThan(0), "Enemies must spawn without any key press.");
Defense25DEnemy enemy = world.Enemies[0];
Vector2 enemyBefore = enemy.Actor.Position;
yield return new WaitForSecondsRealtime(.15f);
Assert.That(Vector2.Distance(enemyBefore, enemy.Actor.Position), Is.GreaterThan(.01f), "The route must advance automatically.");
Keyboard previousKeyboard = Keyboard.current;
Keyboard keyboard = null;
InputSettings previousInputSettings = InputSystem.settings;
InputSettings testInputSettings = Object.Instantiate(previousInputSettings);
try
{
// Batch-mode Game View has no focus. Scope routing to this test,
// preserving the normal InputSystem/Update/FixedUpdate loop.
testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus;
InputSystem.settings = testInputSettings;
keyboard = InputSystem.AddDevice<Keyboard>();
foreach (Key key in new[] { Key.UpArrow, Key.LeftArrow, Key.DownArrow, Key.RightArrow })
{
Vector2 before = world.Player.Position;
Vector2 direction = key == Key.UpArrow ? Vector2.up
: key == Key.LeftArrow ? Vector2.left
: key == Key.DownArrow ? Vector2.down : Vector2.right;
yield return HoldKey(keyboard, key);
Vector2 moved = world.Player.Position - before;
Assert.That(Vector2.Dot(moved, world.View.KeyboardToGround(direction)), Is.GreaterThan(.05f), $"{key} must move the saved-scene player in screen-relative direction. Moved={moved}");
Debug.Log($"COMPARISON_WASD key={key} moved={moved} gameTime={world.GameTime:0.000}");
}
yield return HoldKey(keyboard, Key.P);
Assert.That(world.Paused, Is.True);
Vector2 pausedPosition = world.Player.Position;
float pausedTime = world.GameTime;
yield return HoldKey(keyboard, Key.W);
Assert.That(world.Player.Position, Is.EqualTo(pausedPosition));
Assert.That(world.GameTime, Is.EqualTo(pausedTime));
yield return HoldKey(keyboard, Key.P);
Assert.That(world.Paused, Is.False);
Assert.That(world.GameTime, Is.GreaterThan(pausedTime));
yield return HoldKey(keyboard, Key.P);
yield return HoldKey(keyboard, Key.R);
CompleteStartupArtifactSelection(world);
Assert.That(world.IsRunning && !world.Paused, Is.True);
Assert.That(world.GameTime, Is.LessThan(pausedTime));
Assert.That(world.Player.Position, Is.EqualTo(world.Settings.PlayerSpawn));
Assert.That(Time.timeScale, Is.EqualTo(1f));
}
finally
{
if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard);
InputSystem.settings = previousInputSettings;
Object.Destroy(testInputSettings);
if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent();
}
}
[UnityTest]
public IEnumerator SavedSceneUsesDefenseSkeletonAndBothPortalRoutesReachTheBase()
{
Object.Destroy(world.gameObject);
yield return null;
Time.timeScale = 1f;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
Assert.That(world, Is.Not.Null);
CompleteStartupArtifactSelection(world);
Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton"));
Assert.That(world.Settings.EnemyArt.Idle.Length, Is.GreaterThan(0));
Assert.That(world.Settings.EnemyArt.Walk.Length, Is.GreaterThan(0));
Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0));
Assert.That(world.Settings.EnemyArt.Idle[0].name, Does.StartWith("Skeleton_Idle"));
Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack"));
Assert.That(world.Settings.EnemyRadius, Is.EqualTo(.275f).Within(.0001f));
Assert.That(world.Settings.EnemyColliderOffset, Is.EqualTo(new Vector2(0f, -.35f)));
Assert.That(world.Settings.EnemyVisualScale, Is.EqualTo(1.25f));
Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null);
Assert.That(world.GetRouteForLane(0)[0], Is.EqualTo(GameplayConstants.Current.Defense.GetPortalPosition(0)));
Assert.That(world.GetRouteForLane(1)[0], Is.EqualTo(GameplayConstants.Current.Defense.GetPortalPosition(1)));
Assert.That(GameObject.Find("South obstacle"), Is.Null,
"The legacy obstacle must be disabled so it cannot block the lower defense route.");
Defense25DEnemy upperWaveEnemy = world.SpawnNextEnemy();
Defense25DEnemy lowerWaveEnemy = world.SpawnNextEnemy();
Assert.That(upperWaveEnemy.IsAvailable && lowerWaveEnemy.IsAvailable, Is.True,
"Both lanes must have living Skeleton actors before the wave capture.");
Assert.That(new[] { upperWaveEnemy.Lane, lowerWaveEnemy.Lane }, Is.EqualTo(new[] { 0, 1 }));
Time.timeScale = 1f;
yield return new WaitForSeconds(.75f);
Assert.That(upperWaveEnemy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_"));
Assert.That(lowerWaveEnemy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_"));
Capture("11-saved-scene-two-route-skeleton-wave-active");
typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth))
.GetSetMethod(true).Invoke(world, new object[] { 100f });
world.Player.ResetAt(new Vector2(0f, 5f));
Time.timeScale = 8f;
yield return new WaitForSecondsRealtime(5f);
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(16), "The saved scene must supply the complete 2x8 portal wave.");
Assert.That(world.Enemies.Count(e => e.Lane == 0), Is.EqualTo(8));
Assert.That(world.Enemies.Count(e => e.Lane == 1), Is.EqualTo(8));
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(16), "Both authored routes must reach the shared base.");
Assert.That(world.Ledger.KillEventCount, Is.Zero, "All 16 resolutions must be arrivals, not deaths.");
Assert.That(world.BaseHealth, Is.EqualTo(100f - 16f * GameplayConstants.Current.Defense.ArrivalBaseDamage));
Assert.That(world.IsRunning, Is.True, "The elevated health is limited to this route-isolation fixture.");
Assert.That(world.RunCleared, Is.False, "The run must remain active until the field rewards are collected.");
Capture("10-saved-scene-two-route-skeleton-wave");
}
[UnityTest]
public IEnumerator PKeyCannotResumeTheRunWhileAFieldRelicChoiceIsOpen()
{
CompleteStartupArtifactSelection(world);
typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth))
.GetSetMethod(true).Invoke(world, new object[] { 100f });
var laneZeroEnemies = new System.Collections.Generic.List<Defense25DEnemy>();
Defense25DEnemy movingEnemy = null;
for (int i = 0; i < GameplayConstants.Current.Defense.PortalEnemyCount * 2; i++)
{
Defense25DEnemy enemy = world.SpawnNextEnemy();
Assert.That(enemy, Is.Not.Null);
if (enemy.Lane == 0) laneZeroEnemies.Add(enemy);
else if (movingEnemy == null) movingEnemy = enemy;
}
Assert.That(movingEnemy, Is.Not.Null);
foreach (Defense25DEnemy enemy in laneZeroEnemies)
world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival);
world.Player.ResetAt(GameplayConstants.Current.Defense.GetPortalPosition(0) + Vector2.up * 1.2f);
world.ReadInput = true;
Physics.SyncTransforms();
float modalDeadline = Time.realtimeSinceStartup + 3f;
while (!world.HasPendingFieldRelicReward && Time.realtimeSinceStartup < modalDeadline)
yield return new WaitForFixedUpdate();
Assert.That(world.HasPendingFieldRelicReward, Is.True,
"Collecting an earned portal reward must open the actual field relic modal.");
Assert.That(world.Paused, Is.True);
Keyboard previousKeyboard = Keyboard.current;
InputSettings previousInputSettings = InputSystem.settings;
InputSettings testInputSettings = Object.Instantiate(previousInputSettings);
Keyboard keyboard = null;
try
{
testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus;
InputSystem.settings = testInputSettings;
keyboard = InputSystem.AddDevice<Keyboard>();
float pausedTime = world.GameTime;
Vector2 pausedEnemyPosition = movingEnemy.Actor.Position;
yield return HoldKey(keyboard, Key.P);
Assert.That(world.HasPendingFieldRelicReward, Is.True);
Assert.That(world.Paused, Is.True, "P must not toggle the blocking field-choice modal.");
yield return new WaitForSecondsRealtime(.2f);
Assert.That(world.GameTime, Is.EqualTo(pausedTime).Within(.0001f));
Assert.That(movingEnemy.Actor.Position, Is.EqualTo(pausedEnemyPosition),
"An active route enemy must remain frozen while the field choice is open.");
}
finally
{
if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard);
InputSystem.settings = previousInputSettings;
Object.Destroy(testInputSettings);
if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent();
}
}
private static IEnumerator HoldKey(Keyboard keyboard, Key key)
{
float until = Time.realtimeSinceStartup + .15f;
while (Time.realtimeSinceStartup < until)
{
InputSystem.QueueStateEvent(keyboard, new KeyboardState(key));
yield return null;
}
InputSystem.QueueStateEvent(keyboard, new KeyboardState());
yield return null;
}
private static void CompleteStartupArtifactSelection(Defense25DComparison comparison)
{
var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(choose, Is.Not.Null);
for (int i = 0; comparison.Paused && i < 3; i++)
Assert.That(choose.Invoke(comparison, new object[] { 0 }), Is.EqualTo(true));
Assert.That(comparison.Paused, Is.False,
"The run fixture must finish the authored startup artifact choices before simulating play.");
}
[UnityTest]
public IEnumerator SavedSceneHudHandlesUnavailableViewportAndRestoresGuiMatrix()
{
Object.Destroy(world.gameObject);
yield return null;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
world.SetPaused(true);
var drawHud = typeof(Defense25DComparison).GetMethod("DrawHud",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(drawHud, Is.Not.Null);
ComparisonHudTestWindow window = ScriptableObject.CreateInstance<ComparisonHudTestWindow>();
try
{
window.Show();
foreach (Vector2Int size in new[] { new Vector2Int(0, 360), new Vector2Int(640, 0), new Vector2Int(1, 1), new Vector2Int(640, 360), new Vector2Int(1399, 799) })
{
bool called = false;
Exception failure = null;
window.Draw = () =>
{
window.Draw = null;
called = true;
Matrix4x4 original = GUI.matrix;
try
{
GUI.matrix = Matrix4x4.TRS(new Vector3(3f, 5f, 0f), Quaternion.identity, new Vector3(2f, 2f, 1f));
Matrix4x4 before = GUI.matrix;
drawHud.Invoke(world, new object[] { Defense25DView.GetViewport(size.x, size.y) });
Assert.That(GUI.matrix, Is.EqualTo(before), "The HUD must restore its caller's GUI state.");
}
catch (Exception error) { failure = error; }
finally { GUI.matrix = original; }
};
window.SendEvent(new Event { type = EventType.Repaint });
Assert.That(called, Is.True, "Exercise the real HUD inside a valid IMGUI callback.");
if (failure != null) throw failure;
LogAssert.NoUnexpectedReceived();
}
}
finally
{
window.Draw = null;
window.Close();
}
}
public sealed class ComparisonHudTestWindow : EditorWindow
{
public Action Draw;
private void OnGUI() => Draw?.Invoke();
}
private Defense25DEnemy DurableTarget(Vector2 root)
{
Defense25DEnemy target = world.SpawnNextEnemy();
target.enabled = false; // Same 3D actor/hurtbox, with the route brain explicitly suspended.
target.Actor.AddModifier(new StatModifier("test-health", CharacterStat.MaxHealth, ModifierOperation.Flat, 90f));
// ResetAt deliberately clears modifiers; apply health before a damage probe by using a cloned definition instead.
var serialized = new SerializedObject(settings.Enemy = Object.Instantiate(settings.Enemy));
serialized.FindProperty("maxHealth").floatValue = 100f;
serialized.ApplyModifiedPropertiesWithoutUndo();
target.Actor.ResetAt(root);
Physics.SyncTransforms();
return target;
}
[UnityTest]
public IEnumerator CastBlocksSlidesAndReportsReal3DContact()
{
settings.GroundY = 2f;
world.Player.ResetAt(Vector2.zero);
GameObject wall = Wall(new Vector2(1f, 0f), new Vector3(.2f, 1.5f, 6f));
world.SetMovementInput(new Vector2(1f, 1f));
for (int i = 0; i < 40; i++) yield return new WaitForFixedUpdate();
Assert.That(world.Player.Position.x, Is.LessThanOrEqualTo(.9f - settings.PlayerRadius + .003f));
Assert.That(world.Player.Position.y, Is.GreaterThan(1f), "Wall slide must retain tangential movement.");
Assert.That(world.Player.Body.position.y, Is.EqualTo(2f).Within(.0001f));
Assert.That(world.Player.ContactCallbacks, Is.GreaterThan(0), "A trigger contact must actually be delivered by Physics3D.");
Assert.That(Physics.ComputePenetration(world.Player.BodyCollider, world.Player.Body.position, Quaternion.identity,
wall.GetComponent<Collider>(), wall.transform.position, wall.transform.rotation, out _, out _), Is.False);
Assert.That(world.GetComponentsInChildren<Rigidbody2D>(true), Is.Empty);
Assert.That(world.GetComponentsInChildren<Collider2D>(true), Is.Empty);
world.SetMovementInput(Vector2.right);
float gauge = world.Gauge;
for (int i = 0; i < 10; i++) yield return new WaitForFixedUpdate();
Assert.That(world.Gauge, Is.EqualTo(gauge).Within(.02f), "Holding into a wall must not charge movement gauge.");
}
[UnityTest]
public IEnumerator DenseEnemyActorsDoNotHideWallWhenMovementCastBufferFills()
{
const int blockerActorCount = 80;
world.Player.ResetAt(Vector2.zero);
for (int i = 0; i < blockerActorCount; i++)
{
GameObject actorObject = new($"Dense movement actor {i}");
actorObject.transform.SetParent(world.transform, false);
actorObject.transform.position = Defense25DActor.OnGround(
new Vector2(.55f + i * .0005f, 0f), settings.GroundY);
actorObject.AddComponent<Defense25DActor>().Configure(world, ComparisonActorKind.Enemy);
}
GameObject wall = Wall(new Vector2(1.2f, 0f), new Vector3(.2f, 1.5f, 6f));
Collider wallCollider = wall.GetComponent<Collider>();
Physics.SyncTransforms();
CapsuleCollider capsule = world.Player.BodyCollider;
float cylinder = Mathf.Max(0f, capsule.height * .5f - capsule.radius);
Vector3 center = Defense25DActor.OnGround(world.Player.Center,
settings.GroundY + capsule.center.y);
RaycastHit[] initialCapacity = new RaycastHit[64];
int initialHitCount = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
center - Vector3.up * cylinder, capsule.radius, Vector3.right,
initialCapacity, 2f + settings.MovementSkin,
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
QueryTriggerInteraction.Ignore);
Assert.That(initialHitCount, Is.EqualTo(initialCapacity.Length),
"The fixture must saturate the original movement hit capacity with real actor colliders.");
Assert.That(initialCapacity.Count(hit => hit.collider != wallCollider &&
hit.collider.name.StartsWith("Dense movement actor", StringComparison.Ordinal)),
Is.GreaterThanOrEqualTo(60),
"Real actor colliders must occupy nearly all of the original movement hit capacity.");
world.Player.Move(Vector2.right * 2f);
Assert.That(world.Player.MovementBlocked, Is.True,
"Dense pass-through actors must not hide the actual wall from the expanded movement query.");
Assert.That(world.Player.LastMovementBlocker, Is.EqualTo(wallCollider));
Assert.That(world.Player.LastMove.x, Is.GreaterThan(.5f).And.LessThan(1f),
"The player must advance through pass-through enemies, then stop before the wall.");
yield return new WaitForFixedUpdate();
Assert.That(world.Player.Position.x, Is.LessThan(1.2f - .1f + settings.MovementSkin));
}
[UnityTest]
public IEnumerator BumpDeduplicatesColliderHitsAndHonorsTargetInterval()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
enemy.Actor.Facing = Vector2.right;
enemy.gameObject.AddComponent<BoxCollider>().size = Vector3.one * .1f;
CombatHitResult? hit = null;
void CaptureHit(CombatHitResult result) { hit = result; }
CombatEvents.OnValidHit += CaptureHit;
try
{
Physics.SyncTransforms();
Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one).Count, Is.EqualTo(1));
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(85f));
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
Assert.That(world.BumpEvents, Is.EqualTo(1));
Assert.That(world.Gauge, Is.EqualTo(GameplayConstants.Current.Artifacts.NormalBumpGaugeGain));
Assert.That(hit.HasValue, Is.True);
Assert.That(hit.Value.Attacker, Is.SameAs(world.Player.gameObject));
Assert.That(hit.Value.Target, Is.SameAs(enemy.gameObject));
Assert.That(hit.Value.IsOrdinaryBump && !hit.Value.IsArtifactHit, Is.True);
Assert.That(hit.Value.SourceId, Is.Null);
Assert.That(hit.Value.BaseBumpDamage, Is.EqualTo(15f));
Assert.That(hit.Value.KnockbackDirection,
Is.EqualTo((enemy.Actor.Position - world.Player.Position).normalized));
Assert.That(hit.Value.HitPosition.x, Is.EqualTo(enemy.Actor.Center.x - enemy.Actor.Radius).Within(.001f));
for (int i = 0; i < 15; i++) yield return new WaitForFixedUpdate();
enemy.Actor.Body.position = Defense25DActor.OnGround(new Vector2(.5f, .205f), settings.GroundY);
Physics.SyncTransforms();
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(2));
}
finally { CombatEvents.OnValidHit -= CaptureHit; }
}
[UnityTest]
public IEnumerator SlimeContactPassesThroughAndEnemyStillStopsAtTerrain()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(100.7f, 100f));
world.Player.ResetAt(new Vector2(100f, 100f));
enemy.Actor.ResetAt(new Vector2(100.7f, 100f));
Physics.SyncTransforms();
world.SetPaused(true);
Assert.That(world.Player.BodyCollider.enabled && enemy.Actor.BodyCollider.enabled, Is.True,
"Pausing the simulation must keep both physical colliders enabled.");
world.Player.Move(Vector2.right * 1.5f);
Assert.That(world.Player.LastMove.x, Is.EqualTo(1.5f).Within(.001f));
Assert.That(world.Player.MovementBlocked, Is.False, "A Slime must not block player movement.");
yield return new WaitForFixedUpdate();
Physics.SyncTransforms();
Assert.That(world.Player.BodyCollider.enabled && enemy.Actor.BodyCollider.enabled, Is.True);
Assert.That(world.Player.Position.x, Is.GreaterThan(enemy.Actor.Position.x), "The player must finish beyond the pass-through Slime.");
enemy.Actor.ResetAt(new Vector2(110f, 100f));
Wall(new Vector2(112f, 100f), new Vector3(.2f, 1.5f, 6f));
Assert.That(enemy.Actor.BodyCollider.enabled, Is.True);
enemy.Actor.Move(Vector2.right * 2f);
yield return new WaitForFixedUpdate();
Assert.That(enemy.Actor.LastMove.x, Is.GreaterThan(0f), "The enemy must move up to the wall before being blocked.");
Assert.That(enemy.Actor.LastMove.x, Is.LessThan(1.7f), "An enemy must still stop at terrain.");
Assert.That(enemy.Actor.Position.x, Is.LessThan(111.7f), "The enemy root must remain on the near side of the wall.");
}
[UnityTest]
public IEnumerator SlimeSideHitDoesNotRecoilOrDamageThePlayer()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
world.Player.ResetAt(Vector2.zero);
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
enemy.Actor.Facing = Vector2.up;
Physics.SyncTransforms();
HitSide classified = BumpCombatMath.ClassifySide(enemy.Actor.Facing,
world.Player.Position - enemy.Actor.Position);
Assert.That(classified, Is.EqualTo(HitSide.Side), "The chosen root positions and facing must form a side hit.");
Assert.That(world.Player.Position, Is.EqualTo(Vector2.zero));
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
Assert.That(world.Player.HasKnockback, Is.False, "Slime crowd hits must not recoil the player.");
Assert.That(world.Player.DamageEvents, Is.Zero, "A side hit must not trigger frontal self-damage.");
Assert.That(world.Player.Position, Is.EqualTo(Vector2.zero), "The player root must remain at the side-hit position.");
yield return null;
}
[UnityTest]
public IEnumerator RearHitCancelsAnActiveWarningOnceInAttackThenRearOrder()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
Assert.That(enemy, Is.Not.Null);
enemy.enabled = true;
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
world.Player.ResetAt(Vector2.zero);
var events = new System.Collections.Generic.List<string>();
void OnRear(GameObject target) { if (target == enemy.gameObject) events.Add("rear"); }
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) events.Add("attack"); }
CombatEvents.OnRearAttackCancelled += OnRear;
CombatEvents.OnAttackCancelled += OnAttack;
try
{
enemy.Step(Time.fixedDeltaTime);
Assert.That(enemy.IsAttacking, Is.True);
enemy.Actor.Facing = Vector2.right;
Physics.SyncTransforms();
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
Assert.That(world.BumpEvents, Is.EqualTo(1));
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
Assert.That(enemy.Actor.IsAlive, Is.True, "The rear hit must leave the Slime available for attack-cancel checks.");
Assert.That(events, Is.EqualTo(new[] { "attack", "rear" }));
}
finally
{
CombatEvents.OnRearAttackCancelled -= OnRear;
CombatEvents.OnAttackCancelled -= OnAttack;
}
yield return null;
}
[UnityTest]
public IEnumerator IdleRearBumpDoesNotPublishCancellationEvents()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f));
int rearCancelled = 0, attacksCancelled = 0;
void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; }
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; }
CombatEvents.OnRearAttackCancelled += OnRear;
CombatEvents.OnAttackCancelled += OnAttack;
try
{
enemy.Actor.Facing = Vector2.right;
world.ResolveBumps(Vector2.right * world.Player.MoveSpeed);
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1));
Assert.That(rearCancelled, Is.Zero);
Assert.That(attacksCancelled, Is.Zero);
}
finally
{
CombatEvents.OnRearAttackCancelled -= OnRear;
CombatEvents.OnAttackCancelled -= OnAttack;
}
yield return null;
}
[UnityTest]
public IEnumerator NaturalAttackCompletionDoesNotPublishCancellationEvents()
{
Defense25DEnemy enemy = world.SpawnNextEnemy();
Assert.That(enemy, Is.Not.Null);
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
int rearCancelled = 0, attacksCancelled = 0;
void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; }
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; }
CombatEvents.OnRearAttackCancelled += OnRear;
CombatEvents.OnAttackCancelled += OnAttack;
try
{
enemy.Step(Time.fixedDeltaTime);
Assert.That(enemy.IsAttacking, Is.True);
for (int i = 0; i < 100 && enemy.IsAttacking; i++) yield return new WaitForFixedUpdate();
Assert.That(enemy.IsAttacking, Is.False);
Assert.That(rearCancelled, Is.Zero);
Assert.That(attacksCancelled, Is.Zero);
}
finally
{
CombatEvents.OnRearAttackCancelled -= OnRear;
CombatEvents.OnAttackCancelled -= OnAttack;
}
yield return null;
}
[UnityTest]
public IEnumerator InvalidAttackTargetCleanupDoesNotPublishCancellationEvents()
{
Defense25DEnemy enemy = world.SpawnNextEnemy();
Assert.That(enemy, Is.Not.Null);
enemy.Actor.ResetAt(new Vector2(.5f, .205f));
int rearCancelled = 0, attacksCancelled = 0;
void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; }
void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; }
CombatEvents.OnRearAttackCancelled += OnRear;
CombatEvents.OnAttackCancelled += OnAttack;
try
{
enemy.Step(Time.fixedDeltaTime);
Assert.That(enemy.IsAttacking, Is.True);
world.Player.gameObject.SetActive(false);
enemy.Step(Time.fixedDeltaTime);
Assert.That(enemy.IsAttacking, Is.False);
Assert.That(rearCancelled, Is.Zero);
Assert.That(attacksCancelled, Is.Zero);
}
finally
{
CombatEvents.OnRearAttackCancelled -= OnRear;
CombatEvents.OnAttackCancelled -= OnAttack;
}
yield return null;
}
[UnityTest]
public IEnumerator VisualHeightDoesNotChangeFootprintAndBroadphaseHasBoundedHeight()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(1f, 0f));
Vector2 center = enemy.Actor.Center;
enemy.Actor.Visual.VisualHeight = 5f;
yield return null;
Assert.That(enemy.Actor.Center, Is.EqualTo(center));
Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one).Count, Is.EqualTo(1));
enemy.Actor.Body.position += Vector3.up * 20f;
Physics.SyncTransforms();
Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one), Is.Empty);
}
[UnityTest]
public IEnumerator CycloneKeepsDamageTicksIndependentOfPauseFramesAndDecoration()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(.7f, .205f));
foreach (bool charged in new[] { false, true })
{
world.Player.ResetAt(Vector2.zero);
enemy.Actor.ResetAt(new Vector2(.7f, .205f));
Physics.SyncTransforms();
world.DebugFillGauge();
world.CycloneVisual.DecorationSpeed = charged ? 4f : 1f;
world.CycloneVisual.Height = charged ? .65f : .15f;
world.CycloneVisual.RadiusScale = charged ? 1.35f : 1f;
Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True);
yield return new WaitForFixedUpdate();
world.SetPaused(true);
float elapsed = world.CycloneElapsed, gameTime = world.GameTime;
int events = enemy.Actor.DamageEvents;
for (int i = 0; i < 5; i++) yield return null;
Assert.That(world.CycloneElapsed, Is.EqualTo(elapsed));
Assert.That(world.GameTime, Is.EqualTo(gameTime));
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(events));
world.SetPaused(false);
for (int i = 0; i < 50 && world.CycloneActive; i++) yield return new WaitForFixedUpdate();
Assert.That(world.CycloneActive, Is.False);
Assert.That(world.CycloneTicks, Is.EqualTo(charged ? 5 : 3));
Assert.That(world.CycloneDamageEvents, Is.EqualTo(charged ? 5 : 3));
Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(charged ? 60f : 82f));
Assert.That(world.Gauge, Is.EqualTo(70f));
Assert.That(world.Player.MoveSpeed, Is.GreaterThan(GameplayConstants.Current.Player.BaseMoveSpeed));
Assert.That(world.CycloneVisual.IsVisible, Is.False);
}
}
[UnityTest]
public IEnumerator CancelDeathDisableAndReuseLeaveNoDelayedHitsOrSummonRespawn()
{
Defense25DEnemy enemy = DurableTarget(new Vector2(.7f, .205f));
world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0));
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
world.DebugFillGauge();
world.TryUseCyclone(true, Vector2.right);
yield return new WaitForFixedUpdate();
world.CancelCyclone();
int hits = enemy.Actor.DamageEvents;
for (int i = 0; i < 40; i++) yield return new WaitForFixedUpdate();
Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(hits));
Assert.That(world.Player.MoveSpeed, Is.EqualTo(GameplayConstants.Current.Player.BaseMoveSpeed));
Defense25DSummon summon = world.Summons[0];
summon.Actor.TakeDamage(999f, Vector2.zero);
world.Player.TakeDamage(999f, Vector2.zero);
float remaining = summon.RespawnRemaining;
for (int i = 0; i < 25; i++) yield return new WaitForFixedUpdate();
Assert.That(world.TowersOperational, Is.False);
Assert.That(summon.RespawnRemaining, Is.EqualTo(remaining));
Assert.That(world.CycloneVisual.IsVisible, Is.False);
world.enabled = false;
Assert.That(world.Player.BodyCollider.enabled, Is.False);
Assert.That(world.Summons.All(s => !s.IsAttacking), Is.True);
world.enabled = true;
world.ResetRun();
yield return new WaitForFixedUpdate();
Assert.That(world.Player.IsAlive && world.Player.BodyCollider.enabled, Is.True);
Assert.That(world.Ledger.SuppliedCount, Is.Zero);
Assert.That(world.Gauge, Is.EqualTo(GameplayConstants.Current.Artifacts.StartingGauge));
Assert.That(world.Enemies.All(e => !e.gameObject.activeSelf), Is.True);
Assert.That(world.Summons, Is.Empty);
}
[UnityTest]
public IEnumerator RoutePreservesWaypointsAndArrivalCannotGrantKillRewardTwice()
{
settings.PortalImmunity = DefenseTowerKind.Summon;
world.Player.ResetAt(new Vector2(-8f, -4f));
Defense25DEnemy enemy = world.SpawnNextEnemy();
float before = world.BaseHealth;
for (int i = 0; i < 30; i++) yield return new WaitForFixedUpdate();
int waypoint = enemy.WaypointIndex;
enemy.Actor.ApplyKnockback(Vector2.up, .5f);
yield return new WaitForFixedUpdate();
Assert.That(enemy.WaypointIndex, Is.EqualTo(waypoint));
while (enemy.IsAvailable && world.GameTime < 45f) yield return new WaitForFixedUpdate();
Assert.That(enemy.IsAvailable, Is.False, "The route enemy must reach the base.");
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(1));
Assert.That(world.Ledger.KillEventCount, Is.Zero);
Assert.That(world.KillCurrency, Is.Zero);
Assert.That(world.BaseHealth, Is.EqualTo(before - GameplayConstants.Current.Defense.ArrivalBaseDamage));
world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival);
Assert.That(world.BaseHealth, Is.EqualTo(before - GameplayConstants.Current.Defense.ArrivalBaseDamage));
}
[UnityTest]
public IEnumerator SummonHitsPhysicallyAndRespawnsWhileTowerIsOperational()
{
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0);
world.Player.ResetAt(pad);
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True);
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
Defense25DEnemy enemy = world.SpawnNextEnemy();
if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, enemy))
enemy = world.SpawnNextEnemy();
enemy.Actor.ResetAt(tower.Position + new Vector2(1f, 0f));
world.Player.ResetAt(pad + new Vector2(-1.5f, -1.5f));
Physics.SyncTransforms();
for (int i = 0; i < 180 && world.Summons.Sum(s => s.AttackEvents) == 0; i++) yield return new WaitForFixedUpdate();
Assert.That(world.Summons.Sum(s => s.AttackEvents), Is.GreaterThan(0));
Defense25DSummon summon = world.Summons[0];
Assert.That(Physics.GetIgnoreCollision(summon.Actor.BodyCollider, world.Player.BodyCollider), Is.True);
Assert.That(Physics.GetIgnoreCollision(summon.Actor.BodyCollider, world.Summons[1].Actor.BodyCollider), Is.False);
int slot = summon.SlotIndex;
summon.Actor.TakeDamage(999f, Vector2.zero);
Assert.That(summon.Actor.BodyCollider.enabled, Is.False);
Defense25DSummon replacement = null;
for (int i = 0; i < 180 && replacement == null; i++)
{
replacement = world.Summons.FirstOrDefault(s => s.SlotIndex == slot && s.Actor.IsAlive);
if (replacement == null) yield return new WaitForFixedUpdate();
}
Assert.That(replacement, Is.Not.Null);
Assert.That(replacement, Is.Not.SameAs(summon));
Assert.That(replacement.Actor.BodyCollider.enabled, Is.True);
Assert.That(tower.SummonCount, Is.EqualTo(tower.TargetSummonCount));
}
[UnityTest]
public IEnumerator ViewportRayAndCameraBasisPreserveGroundCoordinates()
{
GameObject cameraObject = new("Viewport mapping test");
cameraObject.transform.SetParent(world.transform);
Defense25DView display = cameraObject.AddComponent<Defense25DView>();
Camera camera = display.Camera;
camera.orthographic = true;
camera.orthographicSize = 6.5f;
camera.transform.rotation = Quaternion.Euler(50f, 35f, 0f);
camera.transform.position = Vector3.up * 2f - camera.transform.forward * 25f;
Vector2 expected = new(1f, -.5f);
Vector3 uv = camera.WorldToViewportPoint(Defense25DActor.OnGround(expected, 2f));
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
Vector2 screen = viewport.position + Vector2.Scale(new Vector2(uv.x, uv.y), viewport.size);
Assert.That(display.TryScreenToGround(screen, 2f, out Vector2 actual), Is.True);
Assert.That(Vector2.Distance(actual, expected), Is.LessThan(.001f));
Assert.That(display.TryScreenToGround(new Vector2(-1f, -1f), 2f, out _), Is.False);
Assert.That(display.KeyboardToGround(Vector2.one).magnitude, Is.EqualTo(1f).Within(.0001f));
Assert.That(Vector2.Dot(display.KeyboardToGround(Vector2.right),
Defense25DActor.Ground(camera.transform.right).normalized), Is.GreaterThan(.999f));
camera.transform.rotation = Quaternion.identity;
Assert.That(display.TryScreenToGround(viewport.center, 2f, out _), Is.False, "A parallel ray must not replace the last valid aim.");
yield return null;
}
[UnityTest]
public IEnumerator ComparisonScenePresentsPixelsToGameDisplayAndCleansUp()
{
Object.Destroy(world.gameObject);
yield return null;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
world.ReadInput = false;
world.AutoSpawn = false;
world.ResetRun();
world.SetPaused(true);
Defense25DView view = world.View;
Camera output = Camera.allCameras.SingleOrDefault(c => c.targetTexture == null && c.targetDisplay == view.Camera.targetDisplay);
Assert.That(output, Is.Not.Null, "The comparison must have a camera rendering to the Game display, not only an offscreen texture and OnGUI.");
Assert.That(EditorGUIUtility.IsDisplayReferencedByCameras(output.targetDisplay), Is.True,
"Check the same display predicate that controls Unity's No cameras rendering warning.");
// Batch mode automatically renders texture cameras only. Invoke the actual Game view
// display-render path without changing the presentation camera's screen destination.
var renderDisplay = typeof(EditorGUIUtility).GetMethod("RenderPlayModeViewCamerasInternal",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
Assert.That(renderDisplay, Is.Not.Null);
bool renderedToDisplay = false;
void Rendered(ScriptableRenderContext context, Camera camera)
{
if (camera == output) renderedToDisplay = true;
}
for (int i = 0; i < 3; i++) yield return null;
RawImage image = view.GetComponentInChildren<RawImage>();
Assert.That(image, Is.Not.Null);
Assert.That(image.canvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera));
Assert.That(image.canvas.worldCamera, Is.SameAs(output));
Assert.That(image.texture, Is.SameAs(view.Texture));
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
Assert.That(image.rectTransform.rect.size, Is.EqualTo(viewport.size));
RenderTexture presented = new(Screen.width, Screen.height, 24, RenderTextureFormat.ARGB32);
Texture2D screen = new(Screen.width, Screen.height, TextureFormat.RGB24, false);
Texture2D source = new(640, 360, TextureFormat.RGB24, false);
RenderTexture previous = RenderTexture.active;
presented.Create();
RenderPipelineManager.endCameraRendering += Rendered;
try
{
for (int i = 0; i < 3; i++)
{
Canvas.ForceUpdateCanvases();
renderDisplay.Invoke(null, new object[] { presented, output.targetDisplay, Vector2.zero, false, false });
yield return null;
}
Assert.That(renderedToDisplay, Is.True, "The screen camera must actually render through the Game view pipeline.");
Assert.That(output.targetTexture, Is.Null);
RenderTexture.active = presented;
screen.ReadPixels(new Rect(0, 0, screen.width, screen.height), 0, 0);
screen.Apply();
// Match GameView.deviceFlippedTargetInView on top-origin graphics APIs.
// This is the Editor display target's orientation, not the world's texture UVs.
if (SystemInfo.graphicsUVStartsAtTop)
{
Color32[] pixels = screen.GetPixels32();
Color32[] upright = new Color32[pixels.Length];
for (int y = 0; y < screen.height; y++)
Array.Copy(pixels, y * screen.width, upright, (screen.height - 1 - y) * screen.width, screen.width);
screen.SetPixels32(upright);
screen.Apply();
}
RenderTexture.active = view.Texture;
source.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
source.Apply();
view.Camera.Render();
Color32[] sourcePixels = source.GetPixels32();
AssertCapturedGeometry(sourcePixels);
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT") ?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
Directory.CreateDirectory(directory);
File.WriteAllBytes(Path.Combine(directory, "10-display-output.png"), screen.EncodeToPNG());
File.WriteAllBytes(Path.Combine(directory, "10-display-source.png"), source.EncodeToPNG());
int matched = 0, samples = 0;
for (int y = 17; y < 360; y += 31)
for (int x = 13; x < 640; x += 29)
{
Color expected = source.GetPixel(x, y);
Color actual = screen.GetPixel(Mathf.FloorToInt(viewport.x + (x + .5f) * viewport.width / 640f),
Mathf.FloorToInt(viewport.y + (y + .5f) * viewport.height / 360f));
if (Mathf.Abs(actual.r - expected.r) < .02f && Mathf.Abs(actual.g - expected.g) < .02f && Mathf.Abs(actual.b - expected.b) < .02f) matched++;
samples++;
}
Assert.That(matched / (float)samples, Is.GreaterThan(.95f), "The display must present the scene with the same orientation and point-scaled viewport.");
if (viewport.x >= 1f || viewport.y >= 1f)
Assert.That(screen.GetPixel(0, 0).maxColorComponent, Is.LessThan(.01f), "Letterbox pixels must stay black.");
}
finally
{
RenderPipelineManager.endCameraRendering -= Rendered;
RenderTexture.active = previous;
presented.Release();
Object.Destroy(presented);
Object.Destroy(screen);
Object.Destroy(source);
}
RenderTexture ownedTexture = view.Texture;
Camera sceneCamera = view.Camera;
view.enabled = false;
yield return null;
Assert.That(output.isActiveAndEnabled, Is.False);
view.enabled = true;
yield return null;
Assert.That(output.isActiveAndEnabled, Is.True);
Assert.That(view.GetComponentsInChildren<Camera>().Length, Is.EqualTo(2), "Re-enabling must reuse the output camera.");
Object.Destroy(view);
yield return null;
yield return null;
Assert.That(output == null && ownedTexture == null, Is.True, "Removing the view must release its output camera and render texture.");
Assert.That(sceneCamera.targetTexture, Is.Null);
}
[UnityTest]
public IEnumerator CaptureIndependentSceneAndExistingTextureVariants()
{
Time.timeScale = 1f;
Object.Destroy(world.gameObject);
yield return null;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null;
Assert.That(world, Is.Not.Null);
world.ReadInput = false;
world.AutoSpawn = false;
world.ResetRun();
world.SetPaused(true);
for (int i = 0; i < 3; i++) yield return null;
Capture("01-scene");
world.SetPaused(false);
world.DebugFillGauge();
Assert.That(world.TryUseCyclone(false, Vector2.right), Is.True, "The capture must start a real Cyclone.");
for (int i = 0; i < 6; i++) yield return new WaitForFixedUpdate();
world.SetPaused(true);
Assert.That(world.CycloneActive, Is.True, $"Cyclone ended before capture: elapsed={world.CycloneElapsed}, ticks={world.CycloneTicks}.");
Color32[] frontBackPixels = null, ribbonPixels = null;
foreach (string variant in new[] { "02-front-back-cutout", "03-front-back-alpha", "04-ribbon", "05-ribbon-radius", "06-ribbon-height", "07-ribbon-speed" })
{
world.CycloneVisual.Ribbon = string.CompareOrdinal(variant, "04") >= 0;
world.CycloneVisual.Transparent = variant.Contains("alpha");
world.CycloneVisual.RadiusScale = variant.Contains("radius") ? 1.35f : 1f;
world.CycloneVisual.Height = variant.Contains("height") ? .65f : .15f;
world.CycloneVisual.DecorationSpeed = variant.Contains("speed") ? 4f : 1f;
for (int i = 0; i < 3; i++) yield return null;
Assert.That(world.CycloneActive && world.CycloneVisual.IsVisible, Is.True, "The captured effect must still be active and visible.");
Assert.That(world.CycloneVisual.GetComponentsInChildren<SpriteRenderer>().All(r => r.sprite != null),
Is.True, "Existing Cyclone PNG frames must be loaded and assigned, not just an enabled empty renderer.");
Color32[] pixels = Capture(variant);
if (variant == "02-front-back-cutout") frontBackPixels = pixels;
else if (variant == "03-front-back-alpha" || variant == "04-ribbon")
Assert.That(DifferentPixels(frontBackPixels, pixels), Is.GreaterThan(4), variant + " must visibly differ from front/back cutout.");
if (variant == "04-ribbon") ribbonPixels = pixels;
else if (variant.StartsWith("05") || variant.StartsWith("06") || variant.StartsWith("07"))
Assert.That(DifferentPixels(ribbonPixels, pixels), Is.GreaterThan(4), variant + " must visibly change the same-texture ribbon.");
}
world.Player.Body.interpolation = RigidbodyInterpolation.None;
world.Player.Body.position = Defense25DActor.OnGround(new Vector2(-1f, 1.15f), world.Settings.GroundY);
world.Player.transform.position = world.Player.Body.position;
world.CycloneVisual.Ribbon = false;
world.CycloneVisual.DecorationSpeed = 1f;
Physics.SyncTransforms();
for (int i = 0; i < 3; i++) yield return null;
Color32[] occluded = Capture("08-wall-occlusion");
GameObject wall = GameObject.Find("Central wall - slide and occlusion");
Assert.That(wall, Is.Not.Null);
Renderer wallRenderer = wall.GetComponent<Renderer>();
wallRenderer.enabled = false;
for (int i = 0; i < 3; i++) yield return null;
Color32[] exposed = Capture("09-wall-hidden-control");
Assert.That(DifferentPixels(occluded, exposed), Is.GreaterThan(100));
Assert.That(CountCyclonePixels(exposed), Is.GreaterThan(CountCyclonePixels(occluded)),
"Removing the wall must reveal effect pixels previously hidden by real depth.");
wallRenderer.enabled = true;
}
[UnityTest]
public IEnumerator CaptureCycloneFrontBackAndOrbitAtEightFramesForNormalAndCharged()
{
Time.timeScale = 1f;
Object.Destroy(world.gameObject);
yield return null;
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(
"Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single));
world = Object.FindAnyObjectByType<Defense25DComparison>();
yield return null; // Saved scene initialization runs in Start.
Assert.That(world, Is.Not.Null);
world.ReadInput = false;
world.AutoSpawn = false;
world.ShowBoundaries = false;
world.ResetRun();
CompleteStartupArtifactSelection(world);
world.SetPaused(false);
world.DebugFillGauge();
Assert.That(world.View, Is.Not.Null);
Camera sourceCamera = world.View.Camera;
Vector3 originalPosition = sourceCamera.transform.position;
Quaternion originalRotation = sourceCamera.transform.rotation;
float originalSize = sourceCamera.orthographicSize;
System.Reflection.FieldInfo elapsedField = typeof(Defense25DComparison).GetField(
"<CycloneElapsed>k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(elapsedField, Is.Not.Null, "Use paused exact elapsed values for synchronized frame captures.");
System.Reflection.FieldInfo orbitRendererField = typeof(Defense25DCycloneVisual).GetField(
"orbitRenderer", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(orbitRendererField, Is.Not.Null);
MeshRenderer orbitRenderer = (MeshRenderer)orbitRendererField.GetValue(world.CycloneVisual);
SpriteRenderer bodyRenderer = world.Player.Visual.Renderer;
Shader maskShader = Resources.Load<Shader>(
"Artifacts/ThreeColor-v1/CycloneOrbit25D-v1/CycloneBodyMask");
Assert.That(maskShader, Is.Not.Null);
Material orbitMaterial = orbitRenderer.sharedMaterial;
Assert.That(orbitMaterial.shader, Is.EqualTo(maskShader));
Assert.That(orbitMaterial.HasProperty("_BodyMaskEnabled"), Is.True);
try
{
sourceCamera.orthographicSize = 2f;
foreach (bool charged in new[] { false, true })
{
world.CancelCyclone();
world.SetPaused(false);
world.DebugFillGauge();
Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True, "Each visual comparison needs a live Cyclone.");
world.SetPaused(true);
Vector2 lookAt = (world.Player.Position + world.CycloneCenter) * .5f;
Vector3 target = Defense25DActor.OnGround(lookAt, world.Settings.GroundY + .45f);
sourceCamera.transform.position = target - sourceCamera.transform.forward * 10f;
world.CycloneVisual.Height = .15f;
world.CycloneVisual.RadiusScale = 1f;
world.CycloneVisual.DecorationSpeed = 1f;
for (int frame = 0; frame < 8; frame++)
{
elapsedField.SetValue(world, frame * .05f);
for (int i = 0; i < 2; i++) yield return null;
string prefix = charged ? "charged" : "normal";
world.CycloneVisual.Ribbon = false;
yield return null;
Color32[] referencePixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-front-back-frame-{frame:00}");
world.CycloneVisual.Ribbon = true;
yield return null;
Color32[] orbitPixels = CaptureCamera(sourceCamera, $"cyclone-{prefix}-orbit-frame-{frame:00}");
Assert.That(CountCyclonePixels(orbitPixels), Is.GreaterThan(100), "Orbit curves must contribute visible mint pixels.");
Assert.That(DifferentPixels(referencePixels, orbitPixels), Is.GreaterThan(100), "Both captures must reflect their selected renderer.");
}
int restoredBodyPixels = 0;
var playFrame = typeof(Defense25DSprite).GetMethod("StartTimedSequence",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(playFrame, Is.Not.Null);
foreach (bool walking in new[] { false, true })
foreach (bool flip in new[] { false, true })
{
Sprite[] motion = walking ? world.Settings.PlayerArt.Walk : world.Settings.PlayerArt.Idle;
Assert.That(motion.Length, Is.GreaterThan(0));
playFrame.Invoke(world.Player.Visual, new object[] {
new[] { motion[walking ? Mathf.Min(1, motion.Length - 1) : 0] }, 1f, walking ? "Walk" : "Idle" });
world.Player.Facing = flip ? Vector2.left : Vector2.right;
elapsedField.SetValue(world, .1f);
string label = $"mask-{(charged ? "charged" : "normal")}-{(walking ? "walk" : "idle")}-{(flip ? "left" : "right")}";
world.CycloneVisual.ProtectBody = false;
yield return null;
yield return null;
Color32[] unmasked = CaptureCamera(sourceCamera, label + "-off");
world.CycloneVisual.ProtectBody = true;
yield return null;
Color32[] masked = CaptureCamera(sourceCamera, label + "-on");
world.CycloneVisual.SetVisible(false);
yield return null;
Color32[] baseline = CaptureCamera(sourceCamera, label + "-hidden");
world.CycloneVisual.SetVisible(true);
Assert.That(bodyRenderer.flipX, Is.EqualTo(flip));
Assert.That(orbitMaterial.GetTexture("_BodyTex"), Is.EqualTo(bodyRenderer.sprite.texture));
restoredBodyPixels += AssertCycloneBodyAndFeetPixels(sourceCamera, bodyRenderer,
world.Player.Visual.FootPosition, baseline, unmasked, masked, label);
}
Assert.That(restoredBodyPixels, Is.GreaterThan(0), "The mask must remove an actual body overlap in both Cyclone variants.");
world.Player.Visual.ResetVisual();
world.Player.Facing = Vector2.right;
yield return null;
}
}
finally
{
if (sourceCamera != null)
{
sourceCamera.transform.SetPositionAndRotation(originalPosition, originalRotation);
sourceCamera.orthographicSize = originalSize;
}
}
}
private static int AssertCycloneBodyAndFeetPixels(Camera camera, SpriteRenderer body,
Vector3 foot, Color32[] baseline, Color32[] unmasked, Color32[] masked, string label)
{
Sprite sprite = body.sprite;
// Read alpha through a temporary GPU copy; production sprite importers
// deliberately keep CPU readability disabled.
Texture2D texture = new(sprite.texture.width, sprite.texture.height, TextureFormat.RGBA32, false, true);
RenderTexture alphaTarget = RenderTexture.GetTemporary(texture.width, texture.height, 0,
RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
RenderTexture previousTarget = RenderTexture.active;
Graphics.Blit(sprite.texture, alphaTarget);
RenderTexture.active = alphaTarget;
texture.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
texture.Apply();
RenderTexture.active = previousTarget;
RenderTexture.ReleaseTemporary(alphaTarget);
Rect rect = sprite.textureRect;
Vector2 pivot = sprite.pivot - sprite.textureRectOffset;
Vector3 footLocal = body.transform.InverseTransformPoint(foot);
float minY = footLocal.y + Defense25DComparisonSettings.CycloneBodyMaskFootClearancePixels / sprite.pixelsPerUnit;
Matrix4x4 inverse = body.transform.worldToLocalMatrix;
float depth = camera.WorldToScreenPoint(body.transform.position).z;
Vector3 footScreen = camera.WorldToScreenPoint(foot);
float cutoffY = camera.WorldToScreenPoint(body.transform.TransformPoint(new Vector3(footLocal.x, minY, 0))).y;
int bodyPixels = 0, bodyMismatch = 0, restored = 0, feetPixels = 0, outsidePixels = 0;
for (int y = 0; y < 360; y++)
for (int x = 0; x < 640; x++)
{
int index = y * 640 + x;
Vector3 local = inverse.MultiplyPoint3x4(camera.ScreenToWorldPoint(new Vector3(x + .5f, y + .5f, depth)));
float px = local.x * (body.flipX ? -1f : 1f) * sprite.pixelsPerUnit + pivot.x;
float py = local.y * (body.flipY ? -1f : 1f) * sprite.pixelsPerUnit + pivot.y;
bool protectedBody = false;
if (local.y >= minY && px >= 1 && py >= 1 && px < rect.width - 1 && py < rect.height - 1)
{
int tx = Mathf.FloorToInt(rect.x + px), ty = Mathf.FloorToInt(rect.y + py);
// Check interior opaque pixels; exclude the rasterized silhouette boundary.
protectedBody = texture.GetPixel(tx, ty).a >= .4f
&& texture.GetPixel(tx - 1, ty).a >= .4f && texture.GetPixel(tx + 1, ty).a >= .4f
&& texture.GetPixel(tx, ty - 1).a >= .4f && texture.GetPixel(tx, ty + 1).a >= .4f;
}
if (protectedBody)
{
bodyPixels++;
if (!masked[index].Equals(baseline[index])) bodyMismatch++;
if (!unmasked[index].Equals(baseline[index]) && masked[index].Equals(baseline[index])) restored++;
}
else if (!masked[index].Equals(baseline[index])) outsidePixels++;
if (y < cutoffY && y > footScreen.y - 32 && Mathf.Abs(x - footScreen.x) < 48
&& !masked[index].Equals(baseline[index])) feetPixels++;
}
Object.Destroy(texture);
Assert.That(bodyPixels, Is.GreaterThan(20), label + " must sample the opaque torso/head.");
Assert.That(bodyMismatch, Is.Zero, label + " protected body must match effect-hidden pixels.");
Assert.That(feetPixels, Is.GreaterThan(10), label + " foreground feet arcs must remain visible.");
Assert.That(outsidePixels, Is.GreaterThan(100), label + " outside orbit must remain visible.");
Debug.Log($"CYCLONE_BODY_MASK {label} body={bodyPixels} mismatch={bodyMismatch} restored={restored} feet={feetPixels} outside={outsidePixels}");
return restored;
}
private static int CountCyclonePixels(Color32[] pixels) => pixels.Count(p => p.g > 90 && p.g > p.r * 1.7f && p.b > p.r * 1.2f && p.b < p.g);
private static int DifferentPixels(Color32[] a, Color32[] b)
{
int count = 0;
for (int i = 0; i < a.Length; i++) if (!a[i].Equals(b[i])) count++;
return count;
}
private Color32[] Capture(string label)
{
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
Directory.CreateDirectory(directory);
world.View.Camera.Render();
RenderTexture previous = RenderTexture.active;
RenderTexture.active = world.View.Texture;
Texture2D image = new(640, 360, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
image.Apply();
RenderTexture.active = previous;
Color32[] pixels = image.GetPixels32();
AssertCapturedGeometry(pixels);
File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG());
Texture2D upscale = new(1920, 1080, TextureFormat.RGB24, false);
Color32[] large = new Color32[1920 * 1080];
for (int y = 0; y < 1080; y++)
for (int x = 0; x < 1920; x++) large[y * 1920 + x] = pixels[(y / 3) * 640 + x / 3];
upscale.SetPixels32(large);
upscale.Apply();
File.WriteAllBytes(Path.Combine(directory, label + "-1920x1080.png"), upscale.EncodeToPNG());
Object.Destroy(image);
Object.Destroy(upscale);
return pixels;
}
private Color32[] CaptureCamera(Camera camera, string label)
{
string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
Directory.CreateDirectory(directory);
RenderTexture target = new(640, 360, 24, RenderTextureFormat.ARGB32);
RenderTexture previous = RenderTexture.active;
RenderTexture originalTarget = camera.targetTexture;
camera.targetTexture = target;
camera.Render();
RenderTexture.active = target;
Texture2D image = new(640, 360, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
image.Apply();
Color32[] pixels = image.GetPixels32();
File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG());
RenderTexture.active = previous;
camera.targetTexture = originalTarget;
target.Release();
Object.Destroy(target);
Object.Destroy(image);
return pixels;
}
private void AssertCapturedGeometry(Color32[] pixels)
{
Color32 background = pixels[0];
int scenePixels = pixels.Count(pixel => !pixel.Equals(background));
Assert.That(scenePixels, Is.GreaterThan(10000),
"The saved scene capture must contain substantial rendered map geometry, not a flat clear color.");
Camera camera = world.View.Camera;
for (int pad = 0; pad < 6; pad++)
{
Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad);
Vector3 screen = camera.WorldToScreenPoint(
Defense25DActor.OnGround(ground, world.Settings.GroundY + .035f));
Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane), $"Captured build pad {pad} must be in front of the camera.");
Assert.That(screen.x, Is.InRange(8f, Defense25DView.Width - 8f), $"Captured build pad {pad} must be on-screen.");
Assert.That(screen.y, Is.InRange(8f, Defense25DView.Height - 8f), $"Captured build pad {pad} must be on-screen.");
}
Vector3 playerScreen = camera.WorldToScreenPoint(world.Player.transform.position + Vector3.up * .45f);
Assert.That(CountBrightPixels(pixels, playerScreen, 12), Is.GreaterThan(3),
"The comparison player must contribute rendered sprite pixels.");
}
private static int CountBrightPixels(Color32[] pixels, Vector3 screen, int radius)
{
int count = 0, cx = Mathf.RoundToInt(screen.x), cy = Mathf.RoundToInt(screen.y);
for (int y = -radius; y <= radius; y++)
for (int x = -radius; x <= radius; x++)
{
int px = cx + x, py = cy + y;
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
Color32 pixel = pixels[py * Defense25DView.Width + px];
if (pixel.r > 90 || pixel.g > 90 || pixel.b > 90) count++;
}
return count;
}
}
}