using System.Collections; using System.Collections.Generic; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using BumpCombat.UI; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using UnityEngine.UI; namespace BumpCombat.PlayModeTests { public sealed class VisualAlignmentTests { private static readonly Vector2[] Directions = { Vector2.right, Vector2.left, Vector2.up, Vector2.down, new Vector2(1f, 1f).normalized, new Vector2(-1f, 1f).normalized, new Vector2(1f, -1f).normalized, new Vector2(-1f, -1f).normalized, }; [SetUp] public void SetUp() { RunManager.ForceProductionModeForTests = false; ArtifactRewardController.GrantCatalogForTests = true; Time.timeScale = 1f; } [TearDown] public void TearDown() { RunManager.ForceProductionModeForTests = false; ArtifactRewardController.GrantCatalogForTests = false; Time.timeScale = 1f; } [UnityTest] public IEnumerator ScorchingRay_NormalVisualAndHitsAlignAcrossCardinalAndDiagonalDirections() { yield return AssertScorchingRayAlignment(false); } [UnityTest] public IEnumerator ScorchingRay_ChargedVisualAndHitsAlignAcrossCardinalAndDiagonalDirections() { yield return AssertScorchingRayAlignment(true); } [UnityTest] public IEnumerator PulseRing_RendererEllipseAndGroundTargetFilterStayAligned() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; director.DebugSpawnImmediate(2); yield return null; PlayerController player = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); playerBody.position = new Vector2(1.25f, -0.75f); EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.InstanceID); Assert.That(enemies.Length, Is.GreaterThanOrEqualTo(2)); for (int enemyIndex = 2; enemyIndex < enemies.Length; enemyIndex++) { enemies[enemyIndex].gameObject.SetActive(false); } ActiveArtifactDefinition definition = SelectArtifact( artifacts, ActiveArtifactEffect.Pulse); float radius = definition.ChargedRange; float verticalRadius = 0.8125f; Vector2 diagonal = new Vector2(1f, 1f).normalized; Vector2 targetStartPosition = playerBody.position + new Vector2(radius * diagonal.x, verticalRadius * diagonal.y); float targetHealthBefore = enemies[0].CurrentHealth; PlaceEnemy( enemies[0], targetStartPosition); Vector2 outsideStartPosition = playerBody.position + Vector2.up * (verticalRadius + 0.1f); float outsideHealthBefore = enemies[1].CurrentHealth; PlaceEnemy( enemies[1], outsideStartPosition); AddOverlappingCollider(enemies[1]); Physics2D.SyncTransforms(); EnsureGauge(artifacts, definition.ChargedGaugeCost); Assert.That(artifacts.TryUseCurrent(true), Is.True); GameObject visual = GameObject.Find("Charged Artifact Pulse"); Assert.That(visual, Is.Not.Null); Assert.That( Vector2.Distance((Vector2)visual.transform.position, playerBody.position), Is.LessThan(0.0001f)); Assert.That( visual.GetComponentsInChildren(true), Is.Empty); Assert.That( visual.GetComponentInChildren(), Is.Not.Null); Assert.That(enemies[0].IsStunned, Is.True); Assert.That(enemies[1].IsStunned, Is.False); Assert.That(enemies[0].transform.Find("Stun Runes"), Is.Not.Null); Assert.That(enemies[1].transform.Find("Stun Runes"), Is.Null); Vector2 repelledOffset = enemies[0].GetComponent().position - playerBody.position; Vector2 initialOffset = targetStartPosition - playerBody.position; float boundaryDistance = 1f / Mathf.Sqrt( diagonal.x * diagonal.x / (radius * radius) + diagonal.y * diagonal.y / (verticalRadius * verticalRadius)); Assert.That( Vector2.Distance(initialOffset, repelledOffset), Is.GreaterThanOrEqualTo(definition.ChargedKnockback - 0.01f)); Assert.That( repelledOffset.magnitude, Is.GreaterThanOrEqualTo(boundaryDistance + 0.099f)); Assert.That( repelledOffset.x * repelledOffset.x / (radius * radius) + repelledOffset.y * repelledOffset.y / (verticalRadius * verticalRadius), Is.GreaterThan(1.0001f)); Assert.That( Vector2.Dot(initialOffset.normalized, repelledOffset.normalized), Is.GreaterThan(0.9999f)); Assert.That(enemies[0].CurrentHealth, Is.EqualTo(targetHealthBefore)); Assert.That( enemies[1].GetComponent().position, Is.EqualTo(outsideStartPosition)); Assert.That(enemies[1].CurrentHealth, Is.EqualTo(outsideHealthBefore)); enemies[0].enabled = true; yield return new WaitForSeconds(0.7f); Assert.That(enemies[0].IsStunned, Is.False); } [UnityTest] public IEnumerator LevelUpSelectionPause_DoesNotLeaveArtifactRangeLineRenderer() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; director.DebugSpawnImmediate(1); yield return null; PlayerController player = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player.GetComponent(); ActiveArtifactDefinition definition = SelectArtifact( artifacts, ActiveArtifactEffect.Pulse); EnsureGauge(artifacts, definition.NormalGaugeCost); Assert.That(artifacts.TryUseCurrent(false), Is.True); GameObject visual = GameObject.Find("Artifact Pulse"); Assert.That(visual, Is.Not.Null); ExperienceSystem experience = player.GetComponent(); Assert.That(experience, Is.Not.Null); experience.AddExperience(experience.RequiredExperience); Assert.That(RunManager.Instance.IsSelectionOpen, Is.True); Assert.That(Time.timeScale, Is.Zero); Assert.That( visual.GetComponentsInChildren(true), Is.Empty); RunManager.Instance.SetSelectionOpen(false); Time.timeScale = 1f; yield return new WaitForSecondsRealtime(0.25f); } [UnityTest] public IEnumerator Cyclone_RendererFollowsPlayerBodyWhileItsHitCenterMoves() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; director.DebugSpawnImmediate(2); yield return null; PlayerController player = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); SetPlayerDirection(player, Vector2.right); playerBody.position = new Vector2(1.25f, -0.75f); EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.InstanceID); Assert.That(enemies.Length, Is.GreaterThanOrEqualTo(2)); ActiveArtifactDefinition definition = SelectArtifact( artifacts, ActiveArtifactEffect.Cyclone); PlaceEnemy(enemies[0], playerBody.position + Vector2.right * 0.3f); PlaceEnemy(enemies[1], playerBody.position + Vector2.up * 0.3f); AddOverlappingCollider(enemies[1]); Physics2D.SyncTransforms(); EnsureGauge(artifacts, definition.NormalGaugeCost); List hits = new(); System.Action handler = result => { if (result.SourceId == definition.ArtifactId) { hits.Add(result); } }; CombatEvents.OnValidHit += handler; try { Assert.That(artifacts.TryUseCurrent(false), Is.True); GameObject visual = GameObject.Find("Cyclone"); Assert.That(visual, Is.Not.Null); Vector3 expectedShadowOffset = player.transform.TransformVector( new Vector3(0f, -7.5f / 32f, 0f)); Assert.That( Vector2.Distance( (Vector2)visual.transform.position, playerBody.position + (Vector2)expectedShadowOffset), Is.LessThan(0.0001f)); Assert.That(visual.transform.parent, Is.Null); Assert.That( visual.GetComponentsInChildren(true), Is.Empty); SpriteRenderer[] cycloneRenderers = visual.GetComponentsInChildren(); Assert.That(cycloneRenderers, Has.Length.EqualTo(2)); foreach (SpriteRenderer cycloneRenderer in cycloneRenderers) { AssertUniformScale(cycloneRenderer.transform.lossyScale, 1f); } SpriteRenderer normalBack = visual.transform.Find( "Cyclone Orbit Back").GetComponent(); SpriteRenderer normalFront = visual.transform.Find( "Cyclone Orbit Front").GetComponent(); YSortRenderer ySort = player.GetComponent(); Assert.That(normalBack.sortingOrder, Is.EqualTo(ySort.CalculateSortingOrder() - 1)); Assert.That(normalFront.sortingOrder, Is.EqualTo(ySort.CalculateSortingOrder() + 1)); Assert.That(hits.Count, Is.EqualTo(2)); Assert.That( hits.TrueForAll( hit => hit.Target == enemies[0].gameObject || hit.Target == enemies[1].gameObject), Is.True); Assert.That( hits.Exists(hit => hit.Target == enemies[0].gameObject), Is.True); Assert.That( hits.Exists(hit => hit.Target == enemies[1].gameObject), Is.True); Time.timeScale = 1f; playerBody.position += new Vector2(0f, 0.4f); Physics2D.SyncTransforms(); yield return new WaitForFixedUpdate(); Assert.That(visual, Is.Not.Null); Assert.That( Vector2.Distance( (Vector2)visual.transform.position, playerBody.position + (Vector2)expectedShadowOffset), Is.LessThan(0.0001f)); Assert.That(normalBack.sortingOrder, Is.EqualTo(ySort.CalculateSortingOrder() - 1)); Assert.That(normalFront.sortingOrder, Is.EqualTo(ySort.CalculateSortingOrder() + 1)); float timeout = 1.5f; while (artifacts.IsExecutingArtifact && timeout > 0f) { timeout -= Time.unscaledDeltaTime; yield return null; } Assert.That(timeout, Is.GreaterThan(0f)); } finally { CombatEvents.OnValidHit -= handler; } yield return null; Assert.That(GameObject.Find("Cyclone"), Is.Null); Assert.That(GameObject.Find("Cyclone Orbit Back"), Is.Null); Assert.That(GameObject.Find("Cyclone Orbit Front"), Is.Null); EnsureGauge(artifacts, definition.ChargedGaugeCost); Assert.That(artifacts.TryUseCurrent(true), Is.True); GameObject chargedVisual = GameObject.Find("Charged Cyclone"); Assert.That(chargedVisual, Is.Not.Null); Assert.That( chargedVisual.GetComponentsInChildren(true), Is.Empty); SpriteRenderer[] chargedRenderers = chargedVisual.GetComponentsInChildren(); Assert.That(chargedRenderers, Has.Length.EqualTo(2)); foreach (SpriteRenderer chargedRenderer in chargedRenderers) { AssertUniformScale(chargedRenderer.transform.lossyScale, 1f); } yield return new WaitForSecondsRealtime(0.7f); Assert.That(GameObject.Find("Charged Cyclone"), Is.Null); Assert.That(GameObject.Find("Cyclone Orbit Back"), Is.Null); Assert.That(GameObject.Find("Cyclone Orbit Front"), Is.Null); } [UnityTest] public IEnumerator ThunderCrash_RendererAndFixedFrontHitCenterRejectOutsideBody() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; director.DebugSpawnImmediate(2); yield return null; PlayerController player = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); SetPlayerDirection(player, Vector2.right); playerBody.position = new Vector2(1.25f, -0.75f); Vector2 playerGroundCenter = playerBody.position + (Vector2)player.transform.TransformVector( new Vector2(0f, -0.1f)); EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.InstanceID); Assert.That(enemies.Length, Is.GreaterThanOrEqualTo(2)); ActiveArtifactDefinition definition = SelectArtifact( artifacts, ActiveArtifactEffect.ThunderCrash); float chargedVisualScale = 1.05f * definition.ChargedRange; Vector2 chargedHitCenter = playerGroundCenter + Vector2.right * 0.45f; Vector2 effectCenter = chargedHitCenter - new Vector2(-0.5f, 0.5f) / 32f * chargedVisualScale; PlaceEnemy(enemies[0], chargedHitCenter + Vector2.right * 0.5f); float chargedHitRadiusY = 7.25f / 32f * chargedVisualScale; PlaceEnemy( enemies[1], chargedHitCenter + Vector2.up * (chargedHitRadiusY + 2f)); SetEnemyHealth(enemies[0], 1000f); AddOverlappingCollider(enemies[1]); Physics2D.SyncTransforms(); EnsureGauge(artifacts, definition.ChargedGaugeCost); List hits = new(); System.Action handler = result => { if (result.SourceId == definition.ArtifactId) { hits.Add(result); } }; CombatEvents.OnValidHit += handler; Assert.That(artifacts.TryUseCurrent(true), Is.True); GameObject visual = GameObject.Find("Charged Thunder Crash"); Assert.That(visual, Is.Not.Null); Assert.That( Vector2.Distance((Vector2)visual.transform.position, effectCenter), Is.LessThan(0.0001f)); Assert.That( visual.GetComponentsInChildren(true), Is.Empty); SpriteRenderer[] thunderRenderers = visual.GetComponentsInChildren(true); Assert.That(thunderRenderers, Has.Length.EqualTo(1)); SpriteRenderer reachRenderer = System.Array.Find( thunderRenderers, renderer => renderer.transform.name == "Thunder Crash Sprite"); Assert.That(reachRenderer, Is.Not.Null); Assert.That(reachRenderer.sprite, Is.Not.Null); Assert.That(reachRenderer.transform.localScale.x * 17f / 32f, Is.EqualTo(17f / 32f * chargedVisualScale).Within(0.0001f)); Assert.That(reachRenderer.transform.localScale.y * 7.25f / 32f, Is.EqualTo(chargedHitRadiusY).Within(0.0001f)); Vector2 reachCenter = (Vector2)reachRenderer.transform.position + new Vector2(-0.5f, 0.5f) / 32f * reachRenderer.transform.localScale.y; Assert.That(Vector2.Distance(reachCenter, chargedHitCenter), Is.LessThan(0.0001f)); SpriteRenderer warlockRenderer = System.Array.Find( thunderRenderers, renderer => renderer.transform.name == "Thunder Crash Sprite"); Assert.That(warlockRenderer, Is.Not.Null); foreach (var layer in thunderRenderers) Assert.That(layer.transform.localPosition, Is.EqualTo(Vector3.zero)); AssertUniformScale(warlockRenderer.transform.localScale, chargedVisualScale); Assert.That(warlockRenderer.sprite.pivot, Is.EqualTo(new Vector2(50.5f, 42.5f))); Assert.That(warlockRenderer.sprite.rect, Is.EqualTo(new Rect(0f, 0f, 100f, 100f))); Vector2 fixedVisualPosition = visual.transform.position; playerBody.position += Vector2.down * 0.2f; Assert.That((Vector2)visual.transform.position, Is.EqualTo(fixedVisualPosition)); Time.timeScale = 1f; float timeout = 2f; while (artifacts.IsExecutingArtifact && timeout > 0f) { timeout -= Time.unscaledDeltaTime; yield return null; } CombatEvents.OnValidHit -= handler; Assert.That(timeout, Is.GreaterThan(0f)); Assert.That(hits.Count, Is.EqualTo(5)); Assert.That(hits.TrueForAll(hit => hit.Target == enemies[0].gameObject), Is.True); EnsureGauge(artifacts, definition.NormalGaugeCost); Assert.That(artifacts.TryUseCurrent(false), Is.True); GameObject normalVisual = GameObject.Find("Thunder Crash"); Assert.That(normalVisual, Is.Not.Null); Vector2 normalGroundCenter = playerBody.position + (Vector2)player.transform.TransformVector( new Vector2(0f, -0.1f)) + Vector2.right * 0.45f; Vector2 normalCenter = normalGroundCenter - new Vector2(-0.5f, 0.5f) / 32f * (1.05f * definition.NormalRange); Assert.That( Vector2.Distance((Vector2)normalVisual.transform.position, normalCenter), Is.LessThan(0.0001f)); Assert.That( normalVisual.GetComponentsInChildren(true), Is.Empty); SpriteRenderer normalGroundRenderer = System.Array.Find( normalVisual.GetComponentsInChildren(true), renderer => renderer.transform.name == "Thunder Crash Sprite"); Assert.That(normalGroundRenderer, Is.Not.Null); AssertUniformScale( normalGroundRenderer.transform.localScale, 1.05f * definition.NormalRange); Assert.That(normalGroundRenderer.sprite.pivot, Is.EqualTo(new Vector2(50.5f, 42.5f))); yield return new WaitForSecondsRealtime(0.4f); } [UnityTest] public IEnumerator RunHUD_UsesScorchingRayForHitDebugLabelAndPopup() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; director.DebugSpawnImmediate(1); yield return null; PlayerController player = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); EnemyController enemy = Object.FindAnyObjectByType(); enemy.enabled = false; playerBody.position = new Vector2(1.25f, -0.75f); enemy.GetComponent().position = playerBody.position + Vector2.right * 1.5f; Physics2D.SyncTransforms(); ActiveArtifactDefinition definition = SelectArtifact( artifacts, ActiveArtifactEffect.Phoenix); EnsureGauge(artifacts, definition.NormalGaugeCost); RunHUD hud = Object.FindAnyObjectByType(); Assert.That(hud, Is.Not.Null); Assert.That(artifacts.TryUseCurrent(false), Is.True); Text hitDebugText = (Text)typeof(RunHUD) .GetField("hitDebugText", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(hud); Assert.That(hitDebugText.text, Is.EqualTo("ARTIFACT 잿불의 마검 35")); Text[] texts = Object.FindObjectsByType( FindObjectsInactive.Include, FindObjectsSortMode.None); Assert.That( System.Array.Exists( texts, text => text != hitDebugText && text.gameObject.activeInHierarchy && text.text == "잿불의 마검 35"), Is.True); yield return new WaitForSecondsRealtime(0.2f); } [UnityTest] public IEnumerator PresentationUi_UsesSharedSlicesIconsFontAndGameOverStyle() { yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); Assert.That(hud, Is.Not.Null); SpriteRenderer arenaRenderer = GameObject.Find("Arena") .GetComponent(); Assert.That(arenaRenderer.sharedMaterial, Is.Not.Null); Assert.That( arenaRenderer.sharedMaterial.shader.name, Is.EqualTo("Universal Render Pipeline/2D/Sprite-Unlit-Default")); Image healthFill = (Image)typeof(RunHUD) .GetField("healthFill", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(hud); Text healthText = (Text)typeof(RunHUD) .GetField("healthText", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(hud); Font galmuri = Resources.Load("Presentation/Fonts/Galmuri9"); Assert.That(galmuri, Is.Not.Null); Assert.That( Resources.Load("Presentation/Fonts/neodgm"), Is.Not.Null); Assert.That( Resources.Load("Presentation/Fonts/NanumGothic-Regular"), Is.Not.Null); Assert.That(healthText.font, Is.Not.Null); Assert.That(healthText.font, Is.SameAs(galmuri)); Assert.That(healthFill.sprite, Is.Not.Null); Assert.That(healthFill.type, Is.EqualTo(Image.Type.Filled)); Assert.That( healthFill.transform.parent.GetComponent().type, Is.EqualTo(Image.Type.Sliced)); GameObject artifactHud = GameObject.Find("Active Artifact HUD"); Assert.That(artifactHud, Is.Not.Null); Assert.That( artifactHud.GetComponent().type, Is.EqualTo(Image.Type.Sliced)); Image slotOne = GameObject.Find("Artifact Slot 1") .GetComponent(); Image slotTwo = GameObject.Find("Artifact Slot 2") .GetComponent(); Image slotThree = GameObject.Find("Artifact Slot 3") .GetComponent(); Assert.That(slotOne.sprite, Is.Not.Null); Assert.That(slotTwo.sprite, Is.Not.Null); Assert.That(slotThree.sprite, Is.Not.Null); Assert.That(slotOne.type, Is.EqualTo(Image.Type.Sliced)); Assert.That(slotTwo.type, Is.EqualTo(Image.Type.Sliced)); Assert.That(slotThree.type, Is.EqualTo(Image.Type.Sliced)); Image[] slotImages = slotOne.GetComponentsInChildren(true); Assert.That(slotImages.Length, Is.GreaterThan(1)); Assert.That(slotImages[1].sprite, Is.Not.Null); ExperienceSystem experience = Object.FindAnyObjectByType(); experience.AddExperience(experience.RequiredExperience); yield return null; GameObject levelPanel = GameObject.Find("Level Up Selection"); Assert.That(levelPanel.activeSelf, Is.True); Assert.That(levelPanel.GetComponent().type, Is.EqualTo(Image.Type.Sliced)); for (int i = 1; i <= 3; i++) { Image card = GameObject.Find($"Option Card {i}") .GetComponent(); Text optionText = GameObject.Find($"Option {i}") .GetComponent(); RectTransform cardRect = card.rectTransform; RectTransform textRect = optionText.rectTransform; Assert.That(card.sprite, Is.Not.Null); Assert.That(card.type, Is.EqualTo(Image.Type.Sliced)); Assert.That(card.raycastTarget, Is.False); Assert.That(card.preserveAspect, Is.False); Assert.That(cardRect.anchorMin, Is.EqualTo(textRect.anchorMin)); Assert.That(cardRect.anchorMax, Is.EqualTo(textRect.anchorMax)); Assert.That(cardRect.pivot, Is.EqualTo(textRect.pivot)); Assert.That(cardRect.anchoredPosition, Is.EqualTo(textRect.anchoredPosition)); Assert.That(cardRect.sizeDelta, Is.EqualTo(textRect.sizeDelta)); Assert.That(card.transform.GetSiblingIndex(), Is.EqualTo(optionText.transform.GetSiblingIndex() - 1)); Assert.That(optionText.raycastTarget, Is.False); } Text levelTitle = levelPanel.transform.Find("Title").GetComponent(); Assert.That(levelTitle.resizeTextForBestFit, Is.True); Assert.That(levelTitle.resizeTextMinSize, Is.EqualTo(24)); Assert.That(levelTitle.resizeTextMaxSize, Is.EqualTo(38)); Assert.That(levelTitle.horizontalOverflow, Is.EqualTo(HorizontalWrapMode.Overflow)); Assert.That(levelTitle.verticalOverflow, Is.EqualTo(VerticalWrapMode.Truncate)); levelPanel.SetActive(false); RunManager.Instance.SetSelectionOpen(false); RunManager.Instance.EndRun(); yield return null; GameObject gameOver = GameObject.Find("Game Over"); Assert.That(gameOver.activeSelf, Is.True); Assert.That(gameOver.GetComponent().type, Is.EqualTo(Image.Type.Sliced)); Assert.That(gameOver.GetComponent().sprite, Is.Not.Null); } [UnityTest] public IEnumerator RunHUD_ReferenceCanvasLayoutBoundsAreSafeFor1280And1920() { yield return LoadCombatPrototype(); Canvas canvas = Object.FindAnyObjectByType(); CanvasScaler scaler = canvas.GetComponent(); Assert.That(scaler.referenceResolution, Is.EqualTo(new Vector2(1920f, 1080f))); RectTransform status = GameObject.Find("Run Status HUD") .GetComponent(); RectTransform artifact = GameObject.Find("Active Artifact HUD") .GetComponent(); RectTransform timer = GameObject.Find("Timer Panel") .GetComponent(); RectTransform debug = GameObject.Find("Debug Event Spawn Buttons") .GetComponent(); RectTransform minimap = GameObject.Find("Minimap") .GetComponent(); RectTransform minimapMap = GameObject.Find("Map") .GetComponent(); RectTransform minimapViewport = GameObject.Find("Minimap Camera Viewport") .GetComponent(); RunHUD hud = Object.FindAnyObjectByType(); Text eventText = GetPrivateField(hud, "eventText"); Image artifactGaugeFill = GetPrivateField(hud, "artifactGaugeFill"); Text artifactName = GetPrivateField(hud, "artifactNameText"); Text healthText = GetPrivateField(hud, "healthText"); Text levelText = GetPrivateField(hud, "levelText"); Text artifactGaugeText = GetPrivateField(hud, "artifactGaugeText"); Text artifactState = GetPrivateField(hud, "artifactStateText"); Image guardTrack = GetPrivateField(hud, "guardGaugeTrack"); Image guardFill = GetPrivateField(hud, "guardGaugeFill"); Text guardLabel = GetPrivateField(hud, "guardLabelText"); RectTransform gauge = artifactGaugeFill.transform.parent.GetComponent(); Image healthPanelImage = GetPrivateField(hud, "healthFill") .transform.parent.GetComponent(); Image experiencePanelImage = GetPrivateField(hud, "experienceFill") .transform.parent.GetComponent(); Image healthFill = GetPrivateField(hud, "healthFill"); Image experienceFill = GetPrivateField(hud, "experienceFill"); RectTransform healthPanel = healthFill.transform.parent .GetComponent(); RectTransform experiencePanel = experienceFill.transform.parent .GetComponent(); AssertRect(status, Vector2.zero, Vector2.zero, new Vector2(24f, 24f), new Vector2(392f, 136f)); AssertRect(artifact, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-16f, 0f), new Vector2(360f, 136f)); Assert.That(artifact.parent, Is.SameAs(status.transform)); AssertRect(timer, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -24f), new Vector2(180f, 66f)); AssertRect(debug, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f, -24f), new Vector2(240f, 144f)); AssertRect(minimap, Vector2.one, Vector2.one, new Vector2(-24f, -24f), new Vector2(300f, 162f)); AssertRect(minimapMap, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(288f, 150f)); Assert.That(GameObject.Find("Minimap/Title"), Is.Null); Assert.That(minimap.GetComponent().fillCenter, Is.False); Image minimapMapImage = minimapMap.GetComponent(); Assert.That(minimapMapImage.sprite, Is.Null); Assert.That(minimapMapImage.color.a, Is.EqualTo(0.28f).Within(0.001f)); Assert.That(minimapMap.GetComponent(), Is.Not.Null); Rect mapWorldRect = GetWorldRect(minimapMap); Rect viewportWorldRect = GetWorldRect(minimapViewport); Assert.That(viewportWorldRect.xMin, Is.GreaterThanOrEqualTo(mapWorldRect.xMin - 0.01f)); Assert.That(viewportWorldRect.xMax, Is.LessThanOrEqualTo(mapWorldRect.xMax + 0.01f)); Assert.That(viewportWorldRect.yMin, Is.GreaterThanOrEqualTo(mapWorldRect.yMin - 0.01f)); Assert.That(viewportWorldRect.yMax, Is.LessThanOrEqualTo(mapWorldRect.yMax + 0.01f)); AssertRect(eventText.rectTransform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -100f), new Vector2(420f, 28f)); Assert.That(eventText.gameObject.activeSelf, Is.True); Assert.That(eventText.text, Does.Contain("다음")); Assert.That(debug.gameObject.activeSelf, Is.True); AssertRect(gauge, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, -24f), new Vector2(352f, 18f)); AssertRect(healthText.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(78f, 48f), new Vector2(172f, 32f)); Assert.That(healthText.transform.parent, Is.SameAs(status.transform)); AssertRect(healthPanel, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-16f, -7f), new Vector2(352f, 12f)); AssertRect(experiencePanel, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-16f, -39f), new Vector2(352f, 8f)); AssertRect(levelText.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(352f, 24f)); Assert.That(levelText.transform.parent, Is.SameAs(experiencePanel)); AssertRect(artifactGaugeText.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(352f, 26f)); Assert.That(artifactName.transform.parent, Is.SameAs(gauge.transform)); Assert.That(artifactName.gameObject.activeSelf, Is.False); Assert.That(artifactState.rectTransform.anchoredPosition, Is.EqualTo(new Vector2(116f, 21f))); Assert.That(artifactState.resizeTextForBestFit, Is.True); Assert.That(artifactState.resizeTextMinSize, Is.EqualTo(11)); Assert.That(artifactState.resizeTextMaxSize, Is.EqualTo(18)); Assert.That(healthPanelImage.enabled, Is.False); Assert.That(experiencePanelImage.enabled, Is.False); Assert.That(healthFill.rectTransform.rect.width, Is.EqualTo(352f)); Assert.That(experienceFill.rectTransform.rect.width, Is.EqualTo(352f)); Assert.That(experienceFill.rectTransform.rect.height, Is.EqualTo(8f)); AssertRect(guardTrack.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(182f, -3f), new Vector2(18f, 94f)); AssertRect(guardLabel.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(182f, 56f), new Vector2(32f, 20f)); Assert.That(guardTrack.pixelsPerUnitMultiplier, Is.EqualTo(5f)); Assert.That(guardFill.fillMethod, Is.EqualTo(Image.FillMethod.Vertical)); Assert.That(guardFill.fillOrigin, Is.EqualTo(0)); Assert.That(guardFill.rectTransform.offsetMin, Is.EqualTo(new Vector2(2f, 2f))); Assert.That(guardFill.rectTransform.offsetMax, Is.EqualTo(new Vector2(-2f, -2f))); Assert.That(guardFill.fillAmount, Is.EqualTo(1f).Within(0.001f)); Assert.That((Color32)guardFill.color, Is.EqualTo(new Color32(255, 205, 92, 255))); Assert.That(guardLabel.text, Is.EqualTo("D")); Assert.That(guardTrack.transform.parent, Is.SameAs(status.transform)); Assert.That(guardFill.transform.parent, Is.SameAs(guardTrack.transform)); Assert.That(RectanglesOverlap(status, artifact), Is.True); Assert.That(RectanglesOverlap(timer, eventText.rectTransform), Is.False); int previousWidth = Screen.width; int previousHeight = Screen.height; try { int[,] resolutions = { { 1280, 720 }, { 1920, 1080 } }; for (int i = 0; i < resolutions.GetLength(0); i++) { Screen.SetResolution( resolutions[i, 0], resolutions[i, 1], FullScreenMode.Windowed); yield return null; Canvas.ForceUpdateCanvases(); AssertRectInsideCanvas(status, canvas.GetComponent()); AssertRectInsideCanvas(artifact, canvas.GetComponent()); AssertRectInsideCanvas(timer, canvas.GetComponent()); AssertRectInsideCanvas(debug, canvas.GetComponent()); AssertRectInsideCanvas(minimap, canvas.GetComponent()); AssertRectInsideCanvas(eventText.rectTransform, canvas.GetComponent()); } } finally { if (previousWidth > 0 && previousHeight > 0) { Screen.SetResolution( previousWidth, previousHeight, FullScreenMode.Windowed); } } } [UnityTest] public IEnumerator PixelPerfectCamera_PixelSnappingUsesEvenNativeViewportAtOddTarget() { yield return LoadCombatPrototype(); Camera camera = Camera.main; Component pixelPerfect = camera.GetComponent( "UnityEngine.Rendering.Universal.PixelPerfectCamera"); Assert.That(pixelPerfect, Is.Not.Null); PropertyInfo gridSnapping = pixelPerfect.GetType() .GetProperty("gridSnapping"); Assert.That( gridSnapping.GetValue(pixelPerfect).ToString(), Is.EqualTo("PixelSnapping")); RenderTexture target = new(1399, 799, 24, RenderTextureFormat.ARGB32); target.Create(); RenderTexture previousTarget = camera.targetTexture; camera.targetTexture = target; try { camera.Render(); yield return null; Rect pixelRect = camera.pixelRect; Assert.That(pixelRect.width, Is.EqualTo(1398f).Within(0.01f)); Assert.That(pixelRect.height, Is.EqualTo(798f).Within(0.01f)); Assert.That(pixelRect.x, Is.EqualTo(0f).Within(0.01f)); Assert.That(pixelRect.y, Is.EqualTo(0f).Within(0.01f)); Assert.That(pixelRect.width % 2f, Is.EqualTo(0f).Within(0.01f)); Assert.That(pixelRect.height % 2f, Is.EqualTo(0f).Within(0.01f)); Assert.That(camera.aspect, Is.EqualTo(1398f / 798f).Within(0.001f)); Assert.That(camera.orthographicSize, Is.EqualTo(6.234375f).Within(0.01f)); FieldInfo internalField = pixelPerfect.GetType().GetField( "m_Internal", BindingFlags.Instance | BindingFlags.NonPublic); object internalCamera = internalField.GetValue(pixelPerfect); FieldInfo widthField = internalCamera.GetType().GetField( "offscreenRTWidth", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo heightField = internalCamera.GetType().GetField( "offscreenRTHeight", BindingFlags.Instance | BindingFlags.NonPublic); FieldInfo useOffscreenField = internalCamera.GetType().GetField( "useOffscreenRT", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(widthField.GetValue(internalCamera), Is.EqualTo(0)); Assert.That(heightField.GetValue(internalCamera), Is.EqualTo(0)); Assert.That(useOffscreenField.GetValue(internalCamera), Is.False); float correctedOrthoSize = camera.orthographicSize; camera.Render(); Assert.That(camera.pixelRect.width, Is.EqualTo(1398f).Within(0.01f)); Assert.That(camera.pixelRect.height, Is.EqualTo(798f).Within(0.01f)); Assert.That(camera.orthographicSize, Is.EqualTo(correctedOrthoSize).Within(0.01f)); ArenaCameraFollow follow = camera.GetComponent(); bool previousFollowEnabled = follow.enabled; follow.enabled = false; camera.Render(); follow.enabled = previousFollowEnabled; camera.Render(); Assert.That(camera.pixelRect.width, Is.EqualTo(1398f).Within(0.01f)); Assert.That(camera.pixelRect.height, Is.EqualTo(798f).Within(0.01f)); } finally { camera.targetTexture = previousTarget; target.Release(); Object.Destroy(target); } } [UnityTest] public IEnumerator RunHUD_MinimapTracksPlayerAndEventEnemies() { yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); SpawnDirector director = Object.FindAnyObjectByType(); PlayerController player = Object.FindAnyObjectByType(); ArenaBounds arena = ArenaBounds.Resolve(); Assert.That(hud, Is.Not.Null); Assert.That(arena, Is.Not.Null); director.enabled = false; EnemyController[] existingEnemies = Object.FindObjectsByType( FindObjectsSortMode.None); for (int i = 0; i < existingEnemies.Length; i++) { existingEnemies[i].gameObject.SetActive(false); } RunManager.Instance.SetSelectionOpen(false); yield return null; director.DebugSpawnImmediate(1); Assert.That(director.TryDebugSpawnEventEnemy(RunTimedEvent.Elite), Is.True); yield return null; Time.timeScale = 0f; yield return new WaitForSecondsRealtime(0.2f); GameObject minimap = GameObject.Find("Minimap"); Image playerMarker = GameObject.Find("Minimap Player") .GetComponent(); Transform markerRoot = GameObject.Find("Markers").transform; Image eventMarker = null; Image[] markerImages = markerRoot.GetComponentsInChildren(true); for (int i = 0; i < markerImages.Length; i++) { if (((Color32)markerImages[i].color).Equals( new Color32(255, 220, 107, 255))) { eventMarker = markerImages[i]; break; } } RectTransform viewport = GameObject.Find("Minimap Camera Viewport") .GetComponent(); Assert.That(minimap.activeSelf, Is.True); Assert.That( ((Color32)playerMarker.color).Equals( new Color32(106, 236, 255, 255)), Is.True); Assert.That(eventMarker, Is.Not.Null); Assert.That( ((Color32)eventMarker.color).Equals( new Color32(255, 220, 107, 255)), Is.True); Assert.That(eventMarker.rectTransform.rect.width, Is.EqualTo(10f)); Assert.That(viewport.rect.width, Is.GreaterThan(0f)); Assert.That(viewport.rect.height, Is.GreaterThan(0f)); Vector2 oldMarkerPosition = playerMarker.rectTransform.anchoredPosition; Vector2 movedPosition = arena.ClampPlayerPosition(new Vector2(6f, 2f)); player.GetComponent().position = movedPosition; player.transform.position = movedPosition; Physics2D.SyncTransforms(); yield return new WaitForSecondsRealtime(0.1f); Assert.That( playerMarker.rectTransform.anchoredPosition, Is.Not.EqualTo(oldMarkerPosition)); Assert.That( playerMarker.rectTransform.anchoredPosition.x, Is.InRange(-144f, 144f)); Assert.That( playerMarker.rectTransform.anchoredPosition.y, Is.InRange(-75f, 75f)); } [UnityTest] public IEnumerator RunHUD_ArtifactColorSwitchPreservesGaugeAndGuardReadiness() { RunManager.ForceProductionModeForTests = true; ArtifactRewardController.GrantCatalogForTests = false; yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = Object.FindAnyObjectByType(); Image artifactFill = GetPrivateField(hud, "artifactGaugeFill"); Image guardFill = GetPrivateField(hud, "guardGaugeFill"); Assert.That(artifacts.TryAddArtifact(artifacts.GetCatalogArtifactAt(0)), Is.True); Assert.That(artifacts.TryAddArtifact(artifacts.GetCatalogArtifactAt(1)), Is.True); Assert.That(artifacts.TryAddArtifact(artifacts.GetCatalogArtifactAt(4)), Is.True); EnsureGauge(artifacts, 12f); float gaugeBefore = artifacts.CurrentGauge; for (int i = 0; i < 3; i++) { yield return null; Assert.That(artifactFill.color, Is.EqualTo(artifacts.CurrentArtifact.EffectColor)); Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBefore)); Assert.That(guardFill.fillAmount, Is.EqualTo(1f).Within(0.001f)); Assert.That(guardFill.fillMethod, Is.EqualTo(Image.FillMethod.Vertical)); Assert.That(guardFill.fillOrigin, Is.EqualTo(0)); if (i < 2) { Assert.That(artifacts.SelectNext(), Is.True); } } } [UnityTest] public IEnumerator RunHUD_GuardGaugeTracksActivationAndCooldownIndependently() { yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); PlayerHealth health = Object.FindAnyObjectByType(); Image guardFill = GetPrivateField(hud, "guardGaugeFill"); Text guardLabel = GetPrivateField(hud, "guardLabelText"); Assert.That(health, Is.Not.Null); Assert.That(guardFill.fillAmount, Is.EqualTo(1f).Within(0.001f)); Assert.That(guardLabel.text, Is.EqualTo("D")); Assert.That((Color32)guardFill.color, Is.EqualTo(new Color32(255, 205, 92, 255))); SetPrivateField(health, "guardCooldownDuration", 0.8f); SetPrivateField(health, "guardDuration", 0.1f); Assert.That(health.TryActivateGuard(), Is.True); yield return null; Assert.That(health.IsGuarding, Is.True); Assert.That( guardFill.fillAmount, Is.EqualTo(health.GuardReadinessNormalized).Within(0.02f)); Assert.That((Color32)guardFill.color, Is.EqualTo(new Color32(255, 232, 128, 255))); Assert.That((Color32)guardLabel.color, Is.EqualTo(new Color32(255, 232, 128, 255))); yield return new WaitForSecondsRealtime(0.4f); Assert.That(health.IsGuarding, Is.False); float cooldownFill = guardFill.fillAmount; Assert.That( cooldownFill, Is.EqualTo(health.GuardReadinessNormalized).Within(0.02f)); Assert.That(cooldownFill, Is.EqualTo(0.5f).Within(0.15f)); Assert.That((Color32)guardFill.color, Is.EqualTo(new Color32(137, 129, 123, 255))); Assert.That((Color32)guardLabel.color, Is.EqualTo(new Color32(137, 129, 123, 255))); } [UnityTest] public IEnumerator RunHUD_CompactClusterFadesForVisibleActorOverlap() { yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); RectTransform cluster = GameObject.Find("Run Status HUD") .GetComponent(); CanvasGroup group = cluster.GetComponent(); PlayerController player = Object.FindAnyObjectByType(); Rigidbody2D body = player.GetComponent(); MethodInfo refresh = typeof(RunHUD).GetMethod( "UpdateCompactHudVisibility", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(refresh, Is.Not.Null); Assert.That(group.alpha, Is.EqualTo(1f)); Vector3[] corners = new Vector3[4]; cluster.GetWorldCorners(corners); Vector3 screenCenter = (corners[0] + corners[2]) * 0.5f; Vector3 worldCenter = Camera.main.ScreenToWorldPoint( new Vector3(screenCenter.x, screenCenter.y, -Camera.main.transform.position.z)); player.enabled = false; body.position = worldCenter; Physics2D.SyncTransforms(); refresh.Invoke(hud, null); Assert.That(group.alpha, Is.EqualTo(0.4f)); body.position = Vector2.zero; Physics2D.SyncTransforms(); refresh.Invoke(hud, null); Assert.That(group.alpha, Is.EqualTo(1f)); } [UnityTest] public IEnumerator RunHUD_AllTextUsesGalmuriPixelFont() { yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); Font galmuri = Resources.Load("Presentation/Fonts/Galmuri9"); Assert.That(galmuri, Is.Not.Null); Text health = GetPrivateField(hud, "healthText"); Text level = GetPrivateField(hud, "levelText"); Text timer = GetPrivateField(hud, "timerText"); Text enemyCount = GetPrivateField(hud, "enemyCountText"); Text gauge = GetPrivateField(hud, "artifactGaugeText"); Text state = GetPrivateField(hud, "artifactStateText"); Text name = GetPrivateField(hud, "artifactNameText"); Assert.That(health.font, Is.SameAs(galmuri)); Assert.That(level.font, Is.SameAs(galmuri)); Assert.That(timer.font, Is.SameAs(galmuri)); Assert.That(name.font, Is.SameAs(galmuri)); Assert.That(health.fontSize, Is.EqualTo(24)); Assert.That(level.fontSize, Is.EqualTo(18)); Assert.That(timer.fontSize, Is.EqualTo(30)); Assert.That(gauge.fontSize, Is.EqualTo(18)); Assert.That(gauge.rectTransform.rect.size, Is.EqualTo(new Vector2(352f, 26f))); Assert.That(name.fontSize, Is.EqualTo(30)); Assert.That(enemyCount.font, Is.SameAs(galmuri)); Assert.That(state.font, Is.SameAs(galmuri)); Text damagePopupTemplate = GetPrivateField(hud, "damagePopupTemplate"); Assert.That(damagePopupTemplate.font, Is.SameAs(galmuri)); Assert.That(damagePopupTemplate.fontSize, Is.EqualTo(30)); Outline outline = damagePopupTemplate.GetComponent(); Assert.That(outline, Is.Not.Null); Assert.That(outline.effectDistance, Is.EqualTo(Vector2.one)); Assert.That(name.gameObject.activeSelf, Is.False); Assert.That(name.horizontalOverflow, Is.EqualTo(HorizontalWrapMode.Overflow)); Assert.That(name.verticalOverflow, Is.EqualTo(VerticalWrapMode.Overflow)); Canvas.ForceUpdateCanvases(); Text[] compactTexts = { health, level, gauge, state }; foreach (Text compactText in compactTexts) { Assert.That(compactText.isActiveAndEnabled, Is.True); Assert.That(compactText.text, Is.Not.EqualTo("준비"), compactText.name); if (compactText == state && string.IsNullOrEmpty(state.text)) { continue; } Assert.That(compactText.cachedTextGenerator.characterCountVisible, Is.GreaterThan(0), compactText.name); Assert.That(compactText.preferredHeight, Is.LessThanOrEqualTo(compactText.rectTransform.rect.height + 1f), compactText.name); } ActiveArtifactController artifacts = Object.FindAnyObjectByType(); ActiveArtifactDefinition longNameDefinition = SelectArtifact( artifacts, ActiveArtifactEffect.ChainLightning); yield return null; Canvas.ForceUpdateCanvases(); Assert.That(name.text, Is.EqualTo(longNameDefinition.DisplayName)); Assert.That(name.gameObject.activeSelf, Is.False); } [UnityTest] public IEnumerator RunHUD_UpdatesZeroOneTwoAndThreeArtifactSlotsWithoutOverflow() { RunManager.ForceProductionModeForTests = true; ArtifactRewardController.GrantCatalogForTests = false; yield return LoadCombatPrototype(); RunHUD hud = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = Object.FindAnyObjectByType(); Image[] slotIcons = GetPrivateField(hud, "artifactSlotIcons"); Text name = GetPrivateField(hud, "artifactNameText"); Assert.That(slotIcons, Has.Length.EqualTo(3)); Assert.That(slotIcons[0].enabled, Is.False); Assert.That(slotIcons[1].enabled, Is.False); Assert.That(slotIcons[2].enabled, Is.False); Assert.That(name.text, Is.EqualTo("EMPTY")); ActiveArtifactDefinition first = artifacts.GetCatalogArtifactAt(0); ActiveArtifactDefinition second = artifacts.GetCatalogArtifactAt(2); ActiveArtifactDefinition third = artifacts.GetCatalogArtifactAt(4); Assert.That(artifacts.TryAddArtifact(first), Is.True); yield return null; Assert.That(slotIcons[0].enabled, Is.True); Assert.That(slotIcons[1].enabled, Is.False); Assert.That(slotIcons[2].enabled, Is.False); Assert.That(name.text, Is.EqualTo(first.DisplayName)); Assert.That(artifacts.TryAddArtifact(second), Is.True); yield return null; Assert.That(slotIcons[0].enabled, Is.True); Assert.That(slotIcons[1].enabled, Is.True); Assert.That(slotIcons[2].enabled, Is.False); Assert.That(artifacts.TryAddArtifact(third), Is.True); yield return null; Assert.That(slotIcons[0].enabled, Is.True); Assert.That(slotIcons[1].enabled, Is.True); Assert.That(slotIcons[2].enabled, Is.True); Assert.That(artifacts.SelectNext(), Is.True); yield return null; Assert.That(name.text, Is.EqualTo(second.DisplayName)); } [UnityTest] public IEnumerator PlayerDamagePopup_ReportsActualLethalHealthLoss() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerHealth health = Object.FindAnyObjectByType(); RunHUD hud = Object.FindAnyObjectByType(); Font galmuri = Resources.Load("Presentation/Fonts/Galmuri9"); float healthBefore = health.CurrentHealth; float reportedDamage = -1f; health.OnDamageTaken += damage => reportedDamage = damage; Assert.That( health.TryTakeDamage( healthBefore + 25f, Vector2.right, 0f), Is.True); Assert.That(reportedDamage, Is.EqualTo(healthBefore).Within(0.0001f)); Assert.That(health.CurrentHealth, Is.Zero); yield return null; Text popup = FindActiveDamagePopup(); Assert.That(popup, Is.Not.Null); Assert.That(popup.text, Is.EqualTo($"-{Mathf.FloorToInt(healthBefore)}")); Assert.That(popup.font, Is.SameAs(galmuri)); Assert.That(popup.fontSize, Is.EqualTo(30)); Assert.That(popup.color.r, Is.GreaterThan(popup.color.g)); Assert.That( popup.rectTransform.position.y, Is.GreaterThan( Camera.main.WorldToScreenPoint(health.transform.position).y)); yield return new WaitForSecondsRealtime(0.7f); Assert.That(GetPrivateField(hud, "activeDamagePopupCount"), Is.Zero); Assert.That(FindActiveDamagePopup(), Is.Null); } [UnityTest] public IEnumerator PlayerDamagePopup_SkipsInvulnerabilityAndHealing() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerHealth health = Object.FindAnyObjectByType(); RunHUD hud = Object.FindAnyObjectByType(); int eventCount = 0; health.OnDamageTaken += _ => eventCount++; health.GrantInvulnerability(10f); Assert.That( health.TryTakeDamage(10f, Vector2.right, 0f), Is.False); health.Heal(20f); yield return new WaitForSecondsRealtime(0.1f); Assert.That(eventCount, Is.Zero); Assert.That(GetPrivateField(hud, "activeDamagePopupCount"), Is.Zero); Assert.That(FindActiveDamagePopup(), Is.Null); } [UnityTest] public IEnumerator DamagePopupPool_UsesSharedCapAndReturnsAllEntries() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); RunHUD hud = Object.FindAnyObjectByType(); Text popupTemplate = GetPrivateField(hud, "damagePopupTemplate"); Transform popupParent = popupTemplate.transform.parent; CombatHitResult result = new( player.gameObject, player.gameObject, false, HitSide.Front, 4f, Vector2.right, 0f, player.transform.position, showsHitFeedback: false); for (int i = 0; i < 16; i++) { CombatEvents.RaiseValidHit(result); } Assert.That( GetPrivateField(hud, "activeDamagePopupCount"), Is.EqualTo(12)); PlayerHealth health = Object.FindAnyObjectByType(); Assert.That(health.TryTakeDamage(1f, Vector2.right, 0f), Is.True); Assert.That( GetPrivateField(hud, "activeDamagePopupCount"), Is.EqualTo(12)); yield return new WaitForSecondsRealtime(0.7f); Assert.That(GetPrivateField(hud, "activeDamagePopupCount"), Is.Zero); Assert.That(FindActiveDamagePopup(), Is.Null); int popupChildCountBeforeToggle = popupParent.childCount; hud.gameObject.SetActive(false); yield return null; Assert.That(GetPrivateField(hud, "activeDamagePopupCount"), Is.Zero); Assert.That(FindActiveDamagePopup(), Is.Null); Assert.That( popupParent.childCount, Is.LessThanOrEqualTo(popupChildCountBeforeToggle)); hud.gameObject.SetActive(true); yield return null; yield return new WaitForSeconds(health.InvulnerabilityDuration + 0.05f); Assert.That(health.TryTakeDamage(1f, Vector2.right, 0f), Is.True); yield return null; Assert.That(FindActiveDamagePopup(), Is.Not.Null); hud.gameObject.SetActive(false); yield return null; Assert.That( popupParent.childCount, Is.LessThanOrEqualTo(popupChildCountBeforeToggle)); } [UnityTest] public IEnumerator DamagePopup_UsesArtifactIdentityPaletteForNormalChargedAndLegacyHits() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); RunHUD hud = Object.FindAnyObjectByType(); GameObject target = new("Artifact Popup Palette Target"); target.transform.position = player.transform.position + Vector3.right; MethodInfo handleValidHit = typeof(RunHUD).GetMethod( "HandleValidHit", BindingFlags.Instance | BindingFlags.NonPublic); ActiveArtifactEffect[] effects = { ActiveArtifactEffect.Dash, ActiveArtifactEffect.Pulse, ActiveArtifactEffect.Phoenix, ActiveArtifactEffect.Cyclone, ActiveArtifactEffect.ThunderCrash, ActiveArtifactEffect.ChainLightning, }; string[] sourceIds = { "dash", "pulse", "phoenix", "cyclone", "thunder_crash", "chain_lightning", }; Color32[] expectedColors = { new(103, 231, 178, 255), new(255, 100, 100, 255), new(255, 100, 100, 255), new(103, 231, 178, 255), new(104, 179, 255, 255), new(104, 179, 255, 255), }; string[] expectedNames = { "척후의 장화", "군주의 방패", "잿불의 마검", "바람깃 브로치", "뇌격의 수갑", "창뢰의 보주", }; for (int group = 0; group < 3; group++) { bool isArtifactHit = group < 2; bool charged = group == 1; for (int i = 0; i < effects.Length; i++) { // The charged Arc case intentionally carries a stale // legacy source id; the actual effect in the hit result // must take priority whenever it is available. string sourceId = charged && i == 5 ? "pulse" : sourceIds[i]; CombatHitResult result = new( player.gameObject, target, false, HitSide.Back, 100f + group * 10f + i, Vector2.right, 0f, target.transform.position, sourceId: sourceId, usesPositionBonus: true, showsHitFeedback: false, launchSucceeded: charged, isArtifactHit: isArtifactHit, artifactEffect: effects[i], isChargedArtifact: charged); handleValidHit.Invoke(hud, new object[] { result }); } for (int i = 0; i < effects.Length; i++) { int damage = 100 + group * 10 + i; string sourceId = charged && i == 5 ? "PULSE" : sourceIds[i].ToUpperInvariant(); string displayName = charged && i == 5 ? "군주의 방패" : expectedNames[i]; string expectedText = charged ? $"LAUNCH {displayName} {damage}" : $"{displayName} {damage}"; Text popup = FindActiveDamagePopupByText(expectedText); Assert.That(popup, Is.Not.Null, expectedText); AssertPopupColor(popup, expectedColors[i]); } yield return new WaitForSecondsRealtime(0.7f); Assert.That( GetPrivateField(hud, "activeDamagePopupCount"), Is.Zero); } HitSide[] ordinarySides = { HitSide.Front, HitSide.Side, HitSide.Back, }; string[] ordinaryTexts = { "42", "SIDE ×1.2 43", "BACK ×1.5 44", }; Color32[] ordinaryColors = { new(255, 255, 255, 255), new(166, 255, 76, 255), new(255, 191, 51, 255), }; for (int i = 0; i < ordinarySides.Length; i++) { CombatHitResult ordinary = new( player.gameObject, target, false, ordinarySides[i], 42f + i, Vector2.right, 0f, target.transform.position, showsHitFeedback: false); handleValidHit.Invoke(hud, new object[] { ordinary }); Text ordinaryPopup = FindActiveDamagePopupByText(ordinaryTexts[i]); Assert.That(ordinaryPopup, Is.Not.Null, ordinaryTexts[i]); AssertPopupColor(ordinaryPopup, ordinaryColors[i]); yield return new WaitForSecondsRealtime(0.7f); } CombatHitResult fractionalDamage = new( player.gameObject, target, false, HitSide.Front, 14.5f, Vector2.right, 0f, target.transform.position, showsHitFeedback: false); handleValidHit.Invoke(hud, new object[] { fractionalDamage }); Text hitDebugText = GetPrivateField(hud, "hitDebugText"); Assert.That(hitDebugText.text, Is.EqualTo("FRONT 14")); Assert.That(FindActiveDamagePopupByText("14"), Is.Not.Null); Assert.That(FindActiveDamagePopupByText("14.5"), Is.Null); yield return new WaitForSecondsRealtime(0.7f); CombatHitResult unknownSource = new( player.gameObject, target, false, HitSide.Front, 45f, Vector2.right, 0f, target.transform.position, sourceId: "unknown_source", showsHitFeedback: false); handleValidHit.Invoke(hud, new object[] { unknownSource }); Text unknownPopup = FindActiveDamagePopupByText("UNKNOWN_SOURCE 45"); Assert.That(unknownPopup, Is.Not.Null); AssertPopupColor(unknownPopup, new Color32(255, 255, 255, 255)); yield return new WaitForSecondsRealtime(0.7f); Object.Destroy(target); } [UnityTest] public IEnumerator DamagePopup_LongArtifactLabelsStayOnOneVisibleLine() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); RunHUD hud = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = Object.FindAnyObjectByType(); ActiveArtifactDefinition chainLightning = null; for (int i = 0; i < artifacts.CatalogCount; i++) { ActiveArtifactDefinition definition = artifacts.GetCatalogArtifactAt(i); if (definition != null && string.Equals( definition.ArtifactId, "chain_lightning", System.StringComparison.OrdinalIgnoreCase)) { chainLightning = definition; break; } } Assert.That(chainLightning, Is.Not.Null); string previousChainLightningName = chainLightning.DisplayName; SetPrivateField(chainLightning, "displayName", "CHAIN_LIGHTNING"); GameObject target = new("Long Artifact Popup Target"); target.transform.position = player.transform.position + Vector3.right; MethodInfo handleValidHit = typeof(RunHUD).GetMethod( "HandleValidHit", BindingFlags.Instance | BindingFlags.NonPublic); try { CombatHitResult normal = new( player.gameObject, target, false, HitSide.Front, 192f, Vector2.right, 0f, target.transform.position, sourceId: "chain_lightning", showsHitFeedback: false, isArtifactHit: true, artifactEffect: ActiveArtifactEffect.ChainLightning); handleValidHit.Invoke(hud, new object[] { normal }); Text normalPopup = FindActiveDamagePopupByText("CHAIN_LIGHTNING 192"); AssertPopupIsSingleVisibleLine(normalPopup, "CHAIN_LIGHTNING 192"); yield return new WaitForSecondsRealtime(0.7f); CombatHitResult charged = new( player.gameObject, target, false, HitSide.Front, 192f, Vector2.right, 0f, target.transform.position, sourceId: "chain_lightning", showsHitFeedback: false, launchSucceeded: true, isArtifactHit: true, artifactEffect: ActiveArtifactEffect.ChainLightning, isChargedArtifact: true); handleValidHit.Invoke(hud, new object[] { charged }); Text chargedPopup = FindActiveDamagePopupByText( "LAUNCH CHAIN_LIGHTNING 192"); AssertPopupIsSingleVisibleLine( chargedPopup, "LAUNCH CHAIN_LIGHTNING 192"); yield return new WaitForSecondsRealtime(0.7f); } finally { SetPrivateField( chainLightning, "displayName", previousChainLightningName); Object.Destroy(target); } } private static T GetPrivateField(object target, string fieldName) { return (T)target.GetType() .GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(target); } private static void SetPrivateField( object target, string fieldName, T value) { target.GetType() .GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(target, value); } private static Text FindActiveDamagePopup() { Text[] texts = Object.FindObjectsByType( FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < texts.Length; i++) { if (texts[i].gameObject.activeInHierarchy && !string.IsNullOrEmpty(texts[i].text) && texts[i].text[0] == '-') { return texts[i]; } } return null; } private static Text FindActiveDamagePopupByText(string expectedText) { Text[] texts = Object.FindObjectsByType( FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < texts.Length; i++) { if (texts[i].gameObject.activeInHierarchy && texts[i].GetComponent() != null && texts[i].text == expectedText) { return texts[i]; } } return null; } private static void AssertPopupColor(Text popup, Color32 expected) { Assert.That(popup.color.r, Is.EqualTo(expected.r / 255f).Within(0.002f)); Assert.That(popup.color.g, Is.EqualTo(expected.g / 255f).Within(0.002f)); Assert.That(popup.color.b, Is.EqualTo(expected.b / 255f).Within(0.002f)); } private static void AssertPopupIsSingleVisibleLine( Text popup, string expectedText) { Assert.That(popup, Is.Not.Null, expectedText); Canvas.ForceUpdateCanvases(); Assert.That(popup.text, Is.EqualTo(expectedText)); Assert.That(popup.resizeTextForBestFit, Is.False); Assert.That( popup.horizontalOverflow, Is.EqualTo(HorizontalWrapMode.Overflow)); Assert.That( popup.verticalOverflow, Is.EqualTo(VerticalWrapMode.Overflow)); TextGenerator generator = popup.cachedTextGenerator; Assert.That(generator.lineCount, Is.EqualTo(1), expectedText); Assert.That( generator.characterCountVisible, Is.EqualTo(expectedText.Length), expectedText); Assert.That( popup.rectTransform.rect.width, Is.GreaterThanOrEqualTo(popup.preferredWidth - 0.5f), expectedText); } private static void AssertRect( RectTransform rect, Vector2 anchor, Vector2 pivot, Vector2 anchoredPosition, Vector2 size) { Assert.That(rect.anchorMin, Is.EqualTo(anchor)); Assert.That(rect.anchorMax, Is.EqualTo(anchor)); Assert.That(rect.pivot, Is.EqualTo(pivot)); Assert.That(rect.anchoredPosition, Is.EqualTo(anchoredPosition)); Assert.That(rect.rect.size, Is.EqualTo(size)); } private static bool RectanglesOverlap(RectTransform first, RectTransform second) { return GetWorldRect(first).Overlaps(GetWorldRect(second)); } private static void AssertRectInsideCanvas( RectTransform target, RectTransform canvas) { Rect targetRect = GetWorldRect(target); Rect canvasRect = GetWorldRect(canvas); const float tolerance = 1f; Assert.That(targetRect.xMin, Is.GreaterThanOrEqualTo(canvasRect.xMin - tolerance)); Assert.That(targetRect.xMax, Is.LessThanOrEqualTo(canvasRect.xMax + tolerance)); Assert.That(targetRect.yMin, Is.GreaterThanOrEqualTo(canvasRect.yMin - tolerance)); Assert.That(targetRect.yMax, Is.LessThanOrEqualTo(canvasRect.yMax + tolerance)); } private static Rect GetWorldRect(RectTransform rect) { Vector3[] corners = new Vector3[4]; rect.GetWorldCorners(corners); float minX = corners[0].x; float maxX = corners[0].x; float minY = corners[0].y; float maxY = corners[0].y; for (int i = 1; i < corners.Length; i++) { minX = Mathf.Min(minX, corners[i].x); maxX = Mathf.Max(maxX, corners[i].x); minY = Mathf.Min(minY, corners[i].y); maxY = Mathf.Max(maxY, corners[i].y); } return Rect.MinMaxRect(minX, minY, maxX, maxY); } private static IEnumerator AssertScorchingRayAlignment(bool charged) { for (int i = 0; i < Directions.Length; i++) { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; director.DebugSpawnImmediate(2); yield return null; PlayerController player = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); Vector2 direction = Directions[i]; SetPlayerDirection(player, direction); playerBody.position = new Vector2(1.25f, -0.75f); EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.InstanceID); Assert.That(enemies.Length, Is.GreaterThanOrEqualTo(2)); for (int enemyIndex = 2; enemyIndex < enemies.Length; enemyIndex++) { enemies[enemyIndex].gameObject.SetActive(false); } ActiveArtifactDefinition definition = SelectArtifact( artifacts, ActiveArtifactEffect.Phoenix); float range = charged ? definition.ChargedRange : definition.NormalRange; float width = charged ? definition.ChargedWidth : definition.NormalWidth; Vector2 perpendicular = new Vector2(-direction.y, direction.x); PlaceEnemy( enemies[0], playerBody.position + direction * (range * 0.5f)); PlaceEnemy( enemies[1], playerBody.position + direction * (range * 0.5f) + perpendicular * (width * 0.5f + 0.06f)); AddOverlappingCollider(enemies[0]); AddOverlappingCollider(enemies[1]); Physics2D.SyncTransforms(); EnsureGauge( artifacts, charged ? definition.ChargedGaugeCost : definition.NormalGaugeCost); List hits = new(); System.Action handler = result => { if (result.SourceId == definition.ArtifactId) { hits.Add(result); } }; CombatEvents.OnValidHit += handler; Assert.That(artifacts.TryUseCurrent(charged), Is.True); GameObject visual = GameObject.Find( charged ? "Charged Scorching Ray" : "Scorching Ray"); Assert.That(visual, Is.Not.Null); Assert.That( Vector2.Distance((Vector2)visual.transform.position, playerBody.position), Is.LessThan(0.0001f)); Assert.That( Mathf.Abs(Mathf.DeltaAngle( visual.transform.eulerAngles.z, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg)), Is.LessThan(0.001f)); SpriteRenderer renderer = visual.GetComponent(); Assert.That(renderer, Is.Not.Null); Assert.That(renderer.transform.localScale, Is.EqualTo(Vector3.one)); Assert.That(renderer.sprite.rect.width, Is.EqualTo(charged ? 256f : 128f)); Assert.That(renderer.sprite.rect.height, Is.EqualTo(charged ? 128f : 64f)); Assert.That( renderer.sprite.pivot, Is.EqualTo(charged ? new Vector2(16f, 64f) : new Vector2(8f, 32f))); Vector2 bodyStart = renderer.transform.TransformPoint(Vector3.zero); Vector2 bodyEnd = renderer.transform.TransformPoint( new Vector3(range, 0f, 0f)); Assert.That( Vector2.Distance(bodyStart, playerBody.position), Is.LessThan(0.0001f)); Assert.That( Vector2.Distance( bodyEnd, playerBody.position + direction * range), Is.LessThan(0.0001f)); Vector2 bodyTop = renderer.transform.TransformPoint( new Vector3(0f, width * 0.5f, 0f)); Vector2 bodyBottom = renderer.transform.TransformPoint( new Vector3(0f, -width * 0.5f, 0f)); Assert.That( Vector2.Distance(bodyTop, playerBody.position + new Vector2( -direction.y * width * 0.5f, direction.x * width * 0.5f)), Is.LessThan(0.0001f)); Assert.That( Vector2.Distance(bodyBottom, playerBody.position - new Vector2( -direction.y * width * 0.5f, direction.x * width * 0.5f)), Is.LessThan(0.0001f)); Assert.That(hits.Count, Is.EqualTo(1)); Assert.That(hits[0].Target, Is.EqualTo(enemies[0].gameObject)); CombatEvents.OnValidHit -= handler; Time.timeScale = 1f; yield return new WaitForSecondsRealtime(0.2f); } } private static IEnumerator LoadCombatPrototype() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; } private static ActiveArtifactDefinition SelectArtifact( ActiveArtifactController artifacts, ActiveArtifactEffect effect) { for (int i = 0; i < artifacts.OwnedArtifactCount; i++) { if (artifacts.CurrentArtifact != null && artifacts.CurrentArtifact.Effect == effect) { return artifacts.CurrentArtifact; } Assert.That(artifacts.SelectNext(), Is.True); } Assert.Fail($"Artifact {effect} was not found in the debug catalog."); return null; } private static void EnsureGauge( ActiveArtifactController artifacts, float required) { while (artifacts.CurrentGauge < required) { artifacts.AddMovementCharge(1f); } } private static void SetPlayerDirection( PlayerController player, Vector2 direction) { typeof(PlayerController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(player, direction); typeof(PlayerController) .GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(player, direction); } private static void PlaceEnemy( EnemyController enemy, Vector2 position) { enemy.enabled = false; Rigidbody2D body = enemy.GetComponent(); body.linearVelocity = Vector2.zero; body.position = position; } private static void SetEnemyHealth( EnemyController enemy, float health) { typeof(EnemyController) .GetProperty("CurrentHealth") .SetValue(enemy, health); Assert.That(enemy.CurrentHealth, Is.EqualTo(health)); } private static void AddOverlappingCollider(EnemyController enemy) { GameObject child = new("Alignment Overlap Collider"); child.transform.SetParent(enemy.transform, false); BoxCollider2D collider = child.AddComponent(); collider.isTrigger = true; collider.size = new Vector2(0.6f, 0.6f); } private static void AssertUniformScale(Vector3 actual, float expected) { Assert.That(actual.x, Is.EqualTo(expected).Within(0.0001f)); Assert.That(actual.y, Is.EqualTo(expected).Within(0.0001f)); Assert.That(actual.z, Is.EqualTo(expected).Within(0.0001f)); } } }