From a5e9b780984a9c5161fc2664dbe823c77761d074 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Sat, 28 Mar 2026 17:05:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=ED=8F=AC=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=82=AC(=ED=9E=90/=EB=B3=B4=ED=98=B8=EB=A7=89/=EB=B2=84?= =?UTF-8?q?=ED=94=84)=20=EC=9C=84=ED=98=91=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThreatUtility: 공통 위협 생성 유틸리티 (OverlapSphere 기반 반경 내 적 탐색) - OverlapSphereNonAlloc 버퍼 32→256 확장으로 씬 콜라이더 누락 수정 - 위협 배율 체인: SkillGem × ThreatController × Passive - HealEffect: flatThreat + (actualHeal × threatPercent) 공식 적용 - ShieldEffect: flatThreat + (actualShield × threatPercent) 공식 적용 - AbnormalityEffect: flatThreat 고정 위협 생성 - EditMode 유닛 테스트 9/9 통과 (SupportThreatTests) - 테스트 씬 UI 레이아웃 수정 사항 포함 --- Assets/Scenes/Test.unity | 126 ++++++++-------- Assets/_Game/Scripts/Combat/ThreatUtility.cs | 91 ++++++++++++ .../Scripts/Combat/ThreatUtility.cs.meta | 2 + .../Skills/Effects/AbnormalityEffect.cs | 20 ++- .../Scripts/Skills/Effects/HealEffect.cs | 18 ++- .../Scripts/Skills/Effects/ShieldEffect.cs | 33 ++++- Assets/_Game/Scripts/Tests.meta | 8 + Assets/_Game/Tests.meta | 8 + Assets/_Game/Tests/Editor.meta | 8 + .../Editor/Colosseum.Tests.Editor.asmdef | 24 +++ .../Editor/Colosseum.Tests.Editor.asmdef.meta | 7 + .../_Game/Tests/Editor/SupportThreatTests.cs | 140 ++++++++++++++++++ .../Tests/Editor/SupportThreatTests.cs.meta | 2 + Colosseum.slnx | 1 + 14 files changed, 415 insertions(+), 73 deletions(-) create mode 100644 Assets/_Game/Scripts/Combat/ThreatUtility.cs create mode 100644 Assets/_Game/Scripts/Combat/ThreatUtility.cs.meta create mode 100644 Assets/_Game/Scripts/Tests.meta create mode 100644 Assets/_Game/Tests.meta create mode 100644 Assets/_Game/Tests/Editor.meta create mode 100644 Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef create mode 100644 Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef.meta create mode 100644 Assets/_Game/Tests/Editor/SupportThreatTests.cs create mode 100644 Assets/_Game/Tests/Editor/SupportThreatTests.cs.meta diff --git a/Assets/Scenes/Test.unity b/Assets/Scenes/Test.unity index e5d12b08..6df58e9a 100644 --- a/Assets/Scenes/Test.unity +++ b/Assets/Scenes/Test.unity @@ -4943,9 +4943,9 @@ RectTransform: m_Children: [] m_Father: {fileID: 1269404371} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 0} - m_AnchorMax: {x: 0, y: 0} - m_AnchoredPosition: {x: 0, y: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 200, y: -150} m_SizeDelta: {x: 400, y: 100} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &972424301 @@ -10250,9 +10250,9 @@ RectTransform: m_Children: [] m_Father: {fileID: 1269404371} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 0} - m_AnchorMax: {x: 0, y: 0} - m_AnchoredPosition: {x: 0, y: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 200, y: -50} m_SizeDelta: {x: 400, y: 100} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &1873670929 @@ -12497,19 +12497,19 @@ PrefabInstance: m_Modifications: - target: {fileID: 37952942597124734, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 37952942597124734, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 37952942597124734, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 30 objectReference: {fileID: 0} - target: {fileID: 37952942597124734, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} - target: {fileID: 190570274300989800, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_sharedMaterial @@ -12533,19 +12533,19 @@ PrefabInstance: objectReference: {fileID: 2399985188671511309, guid: ef44cbe516f6f9f418375e5b2b73ad8d, type: 2} - target: {fileID: 1133562530543291889, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 1133562530543291889, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 1133562530543291889, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 12 objectReference: {fileID: 0} - target: {fileID: 1133562530543291889, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} - target: {fileID: 1221067101658275850, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_Pivot.x @@ -12573,7 +12573,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 1221067101658275850, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: 600 objectReference: {fileID: 0} - target: {fileID: 1221067101658275850, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_SizeDelta.y @@ -12637,83 +12637,83 @@ PrefabInstance: objectReference: {fileID: 2399985188671511309, guid: ef44cbe516f6f9f418375e5b2b73ad8d, type: 2} - target: {fileID: 2379057061630145361, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 2379057061630145361, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 2379057061630145361, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 210 objectReference: {fileID: 0} - target: {fileID: 2379057061630145361, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 3523350566323011114, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 3523350566323011114, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 3523350566323011114, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 300 objectReference: {fileID: 0} - target: {fileID: 3523350566323011114, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 3939708747385511549, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 3939708747385511549, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 3939708747385511549, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 390 objectReference: {fileID: 0} - target: {fileID: 3939708747385511549, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 4181238112051942263, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4181238112051942263, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4181238112051942263, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 120 objectReference: {fileID: 0} - target: {fileID: 4181238112051942263, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 4523923546265445185, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4523923546265445185, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4523923546265445185, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 480 objectReference: {fileID: 0} - target: {fileID: 4523923546265445185, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 4573149184624272161, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_sharedMaterial @@ -12729,19 +12729,19 @@ PrefabInstance: objectReference: {fileID: 2399985188671511309, guid: ef44cbe516f6f9f418375e5b2b73ad8d, type: 2} - target: {fileID: 6101051669567451606, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6101051669567451606, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6101051669567451606, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 570 objectReference: {fileID: 0} - target: {fileID: 6101051669567451606, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 6130640029172101558, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_sharedMaterial @@ -12749,19 +12749,19 @@ PrefabInstance: objectReference: {fileID: 2399985188671511309, guid: ef44cbe516f6f9f418375e5b2b73ad8d, type: 2} - target: {fileID: 6345035962906402317, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6345035962906402317, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6345035962906402317, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 30 objectReference: {fileID: 0} - target: {fileID: 6345035962906402317, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -80 objectReference: {fileID: 0} - target: {fileID: 6573329606355463389, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_sharedMaterial @@ -12789,19 +12789,19 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6996006525341314896, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6996006525341314896, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6996006525341314896, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 12 objectReference: {fileID: 0} - target: {fileID: 6996006525341314896, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} - target: {fileID: 7080500501054768885, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_sharedMaterial @@ -12809,19 +12809,19 @@ PrefabInstance: objectReference: {fileID: 2399985188671511309, guid: ef44cbe516f6f9f418375e5b2b73ad8d, type: 2} - target: {fileID: 7338693974700040038, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7338693974700040038, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7338693974700040038, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 12 objectReference: {fileID: 0} - target: {fileID: 7338693974700040038, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} - target: {fileID: 7777120333480524073, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: skillSlots.Array.size @@ -12853,19 +12853,19 @@ PrefabInstance: objectReference: {fileID: 5155748580713623445} - target: {fileID: 7779132291636019725, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7779132291636019725, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7779132291636019725, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 12 objectReference: {fileID: 0} - target: {fileID: 7779132291636019725, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} - target: {fileID: 7784750563836883913, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_sharedMaterial @@ -12873,35 +12873,35 @@ PrefabInstance: objectReference: {fileID: 2399985188671511309, guid: ef44cbe516f6f9f418375e5b2b73ad8d, type: 2} - target: {fileID: 7907435898345579098, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7907435898345579098, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7907435898345579098, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 12 objectReference: {fileID: 0} - target: {fileID: 7907435898345579098, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} - target: {fileID: 8796771405208194934, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 8796771405208194934, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchorMin.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 8796771405208194934, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 12 objectReference: {fileID: 0} - target: {fileID: 8796771405208194934, guid: 49cc51b51d25b2e4f85ceec039fb545c, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: -12 objectReference: {fileID: 0} m_RemovedComponents: [] m_RemovedGameObjects: [] diff --git a/Assets/_Game/Scripts/Combat/ThreatUtility.cs b/Assets/_Game/Scripts/Combat/ThreatUtility.cs new file mode 100644 index 00000000..cf035fb0 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/ThreatUtility.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; + +using UnityEngine; + +using Colosseum.Enemy; +using Colosseum.Passives; +using Colosseum.Skills; + +namespace Colosseum.Combat +{ + /// + /// 힐, 보호막, 버프 등 비공격 스킬에서 위협을 생성할 때 사용하는 공통 유틸리티입니다. + /// 시전자 주변의 적을 탐색하고, 위협 배율 체인을 적용하여 위협을 누적합니다. + /// + public static class ThreatUtility + { + private const float DefaultThreatRadius = 50f; + + private static readonly Collider[] overlapBuffer = new Collider[256]; + private static readonly HashSet processedEnemies = new HashSet(); + + /// + /// 시전자 주변의 적들에게 위협을 분배합니다. + /// 위협 공식: baseThreat × (Gem 배율 × ThreatController 배율 × Passive 배율) + /// + /// 시전자 (위협을 얻을 주체) + /// 배율 적용 전 기본 위협 수치 + /// 위협을 생성할 반경 (기본 50m) + public static void GenerateThreatOnNearbyEnemies(GameObject caster, float baseThreat, float radius = DefaultThreatRadius) + { + if (caster == null || baseThreat <= 0f) + return; + + float multiplier = ResolveThreatMultiplier(caster); + float finalThreat = baseThreat * multiplier; + + processedEnemies.Clear(); + int hitCount = Physics.OverlapSphereNonAlloc( + caster.transform.position, + Mathf.Max(radius, 1f), + overlapBuffer); + + for (int i = 0; i < hitCount; i++) + { + Collider hit = overlapBuffer[i]; + if (hit == null) + continue; + + EnemyBase enemy = hit.GetComponentInParent(); + if (enemy == null || processedEnemies.Contains(enemy)) + continue; + + if (!enemy.UseThreatSystem || enemy.IsDead) + continue; + + // 같은 팀(적이 아님)은 제외 + if (Colosseum.Team.IsSameTeam(caster, enemy.gameObject)) + continue; + + processedEnemies.Add(enemy); + enemy.AddThreat(caster, finalThreat); + } + + if (processedEnemies.Count > 0) + { + CombatBalanceTracker.RecordThreat(caster, finalThreat); + } + } + + /// + /// 시전자의 전체 위협 배율을 계산합니다. + /// 체인: SkillGem 배율 × ThreatController 배율 × Passive 배율 + /// + public static float ResolveThreatMultiplier(GameObject source) + { + if (source == null) + return 1f; + + float gemMultiplier = SkillRuntimeModifierUtility.GetThreatMultiplier(source); + + ThreatController threatController = source.GetComponent(); + float runtimeMultiplier = threatController != null + ? Mathf.Max(0f, threatController.CurrentThreatMultiplier) + : 1f; + + float passiveMultiplier = PassiveRuntimeModifierUtility.GetThreatGeneratedMultiplier(source); + + return gemMultiplier * runtimeMultiplier * passiveMultiplier; + } + } +} diff --git a/Assets/_Game/Scripts/Combat/ThreatUtility.cs.meta b/Assets/_Game/Scripts/Combat/ThreatUtility.cs.meta new file mode 100644 index 00000000..973e9ad6 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/ThreatUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e20589b8f84ac54e8417a01d5ca9be0 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Skills/Effects/AbnormalityEffect.cs b/Assets/_Game/Scripts/Skills/Effects/AbnormalityEffect.cs index 5acef2bf..36169298 100644 --- a/Assets/_Game/Scripts/Skills/Effects/AbnormalityEffect.cs +++ b/Assets/_Game/Scripts/Skills/Effects/AbnormalityEffect.cs @@ -1,11 +1,15 @@ using UnityEngine; + using Colosseum.Abnormalities; +using Colosseum.Combat; +using Colosseum.Skills; namespace Colosseum.Skills.Effects { /// - /// 이상 상태 효과 - /// AbnormalityManager를 통해 대상에게 이상 상태를 적용합니다. + /// 이상 상태 효과. + /// AbnormalityManager를 통해 대상에게 이상 상태를 적용하며, + /// 버프 적용 시 적에게 위협을 생성할 수 있습니다. /// [CreateAssetMenu(fileName = "AbnormalityEffect", menuName = "Colosseum/Skills/Effects/Abnormality")] public class AbnormalityEffect : SkillEffect @@ -14,6 +18,12 @@ namespace Colosseum.Skills.Effects [Tooltip("적용할 이상 상태 데이터")] [SerializeField] private AbnormalityData abnormalityData; + [Header("Threat")] + [Tooltip("버프 적용 시 생성할 고정 위협 수치")] + [Min(0f)] [SerializeField] private float flatThreatAmount = 5f; + [Tooltip("위협을 생성할 반경 (시전자 기준)")] + [Min(0f)] [SerializeField] private float threatRadius = 50f; + protected override void ApplyEffect(GameObject caster, GameObject target) { if (target == null) return; @@ -24,6 +34,12 @@ namespace Colosseum.Skills.Effects if (abnormalityManager == null) return; abnormalityManager.ApplyAbnormality(abnormalityData, caster); + + // 위협 생성: 고정 수치 + if (flatThreatAmount > 0f) + { + ThreatUtility.GenerateThreatOnNearbyEnemies(caster, flatThreatAmount, threatRadius); + } } /// diff --git a/Assets/_Game/Scripts/Skills/Effects/HealEffect.cs b/Assets/_Game/Scripts/Skills/Effects/HealEffect.cs index 16b277a0..5781dda5 100644 --- a/Assets/_Game/Scripts/Skills/Effects/HealEffect.cs +++ b/Assets/_Game/Scripts/Skills/Effects/HealEffect.cs @@ -8,7 +8,8 @@ using Colosseum.Skills; namespace Colosseum.Skills.Effects { /// - /// 치료 효과 + /// 치료 효과. + /// 회복량에 비례하여 적에게 위협을 생성할 수 있습니다. /// [CreateAssetMenu(fileName = "HealEffect", menuName = "Colosseum/Skills/Effects/Heal")] public class HealEffect : SkillEffect @@ -18,6 +19,14 @@ namespace Colosseum.Skills.Effects [Tooltip("회복력 계수 (1.0 = 100%)")] [Min(0f)] [SerializeField] private float healScaling = 1f; + [Header("Threat")] + [Tooltip("힐 사용 시 항상 생성할 고정 위협 수치")] + [Min(0f)] [SerializeField] private float flatThreatAmount = 5f; + [Tooltip("실제 회복량에 대한 위협 비율 (1.0 = 100%)")] + [Range(0f, 10f)] [SerializeField] private float threatPercentOfHeal = 0.5f; + [Tooltip("위협을 생성할 반경 (시전자 기준)")] + [Min(0f)] [SerializeField] private float threatRadius = 50f; + protected override void ApplyEffect(GameObject caster, GameObject target) { if (target == null) return; @@ -31,6 +40,13 @@ namespace Colosseum.Skills.Effects { float actualHeal = damageable.Heal(totalHeal); CombatBalanceTracker.RecordHeal(caster, target, actualHeal); + + // 위협 생성: 고정 수치 + (실제 회복량 × 비율) + float threat = flatThreatAmount + (actualHeal * threatPercentOfHeal); + if (threat > 0f) + { + ThreatUtility.GenerateThreatOnNearbyEnemies(caster, threat, threatRadius); + } } } diff --git a/Assets/_Game/Scripts/Skills/Effects/ShieldEffect.cs b/Assets/_Game/Scripts/Skills/Effects/ShieldEffect.cs index 904e1c92..45f1110a 100644 --- a/Assets/_Game/Scripts/Skills/Effects/ShieldEffect.cs +++ b/Assets/_Game/Scripts/Skills/Effects/ShieldEffect.cs @@ -12,7 +12,8 @@ namespace Colosseum.Skills.Effects { /// /// 보호막 효과입니다. - /// 대상에게 일정 시간 동안 피해를 흡수하는 보호막을 부여합니다. + /// 대상에게 일정 시간 동안 피해를 흡수하는 보호막을 부여하며, + /// 보호막 수치에 비례하여 적에게 위협을 생성할 수 있습니다. /// [CreateAssetMenu(fileName = "ShieldEffect", menuName = "Colosseum/Skills/Effects/Shield")] public class ShieldEffect : SkillEffect @@ -31,25 +32,43 @@ namespace Colosseum.Skills.Effects [Tooltip("보호막 활성 여부를 나타내는 이상상태 데이터")] [SerializeField] private AbnormalityData shieldStateAbnormality; + [Header("Threat")] + [Tooltip("보호막 사용 시 항상 생성할 고정 위협 수치")] + [Min(0f)] [SerializeField] private float flatThreatAmount = 5f; + [Tooltip("실제 보호막 수치에 대한 위협 비율 (1.0 = 100%)")] + [Range(0f, 10f)] [SerializeField] private float threatPercentOfShield = 0.5f; + [Tooltip("위협을 생성할 반경 (시전자 기준)")] + [Min(0f)] [SerializeField] private float threatRadius = 50f; + protected override void ApplyEffect(GameObject caster, GameObject target) { if (target == null) return; float totalShield = CalculateShield(caster); + float actualShield = 0f; + PlayerNetworkController playerNetworkController = target.GetComponent(); if (playerNetworkController != null) { - float actualShield = playerNetworkController.ApplyShield(totalShield, duration, shieldStateAbnormality, caster); + actualShield = playerNetworkController.ApplyShield(totalShield, duration, shieldStateAbnormality, caster); CombatBalanceTracker.RecordShield(caster, target, actualShield); - return; + } + else + { + EnemyBase enemyBase = target.GetComponent(); + if (enemyBase != null) + { + actualShield = enemyBase.ApplyShield(totalShield, duration, shieldStateAbnormality, caster); + CombatBalanceTracker.RecordShield(caster, target, actualShield); + } } - EnemyBase enemyBase = target.GetComponent(); - if (enemyBase != null) + // 위협 생성: 고정 수치 + (실제 보호막량 × 비율) + float threat = flatThreatAmount + (actualShield * threatPercentOfShield); + if (threat > 0f) { - float actualShield = enemyBase.ApplyShield(totalShield, duration, shieldStateAbnormality, caster); - CombatBalanceTracker.RecordShield(caster, target, actualShield); + ThreatUtility.GenerateThreatOnNearbyEnemies(caster, threat, threatRadius); } } diff --git a/Assets/_Game/Scripts/Tests.meta b/Assets/_Game/Scripts/Tests.meta new file mode 100644 index 00000000..744c405f --- /dev/null +++ b/Assets/_Game/Scripts/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 989936a82b03b3b4eb3b2aac7da092cb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Tests.meta b/Assets/_Game/Tests.meta new file mode 100644 index 00000000..1d91989e --- /dev/null +++ b/Assets/_Game/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4ec0c6ae97a058e48b97372dbb8d437f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Tests/Editor.meta b/Assets/_Game/Tests/Editor.meta new file mode 100644 index 00000000..0f994110 --- /dev/null +++ b/Assets/_Game/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b36ab2294709c5b4e88bf7c3523725d1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef b/Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef new file mode 100644 index 00000000..203e7a57 --- /dev/null +++ b/Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef @@ -0,0 +1,24 @@ +{ + "name": "Colosseum.Tests.Editor", + "rootNamespace": "Colosseum.Tests", + "references": [ + "Colosseum.Game", + "UnityEditor.TestRunner", + "UnityEngine.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef.meta b/Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef.meta new file mode 100644 index 00000000..2a20d92a --- /dev/null +++ b/Assets/_Game/Tests/Editor/Colosseum.Tests.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3b0e3810e0c57e844b5fa7728b44552b +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Tests/Editor/SupportThreatTests.cs b/Assets/_Game/Tests/Editor/SupportThreatTests.cs new file mode 100644 index 00000000..cc0e5d04 --- /dev/null +++ b/Assets/_Game/Tests/Editor/SupportThreatTests.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; + +using Colosseum.Combat; +using Colosseum.Stats; + +namespace Colosseum.Tests +{ + /// + /// 힐/보호막/버프 스킬의 위협 생성 기능을 검증하는 EditMode 테스트입니다. + /// ThreatUtility의 배율 계산과 위협 생성 로직을 검증합니다. + /// EnemyBase는 NetworkBehaviour이므로 EditMode에서 인스턴스화할 수 없어 + /// 대신 위협 테이블의 동작을 모킹하여 로직을 검증합니다. + /// + public class SupportThreatTests + { + // ================================================================ + // ResolveThreatMultiplier 테스트 + // ================================================================ + + [Test] + public void ResolveThreatMultiplier_NullSource_Returns1() + { + float multiplier = ThreatUtility.ResolveThreatMultiplier(null); + Assert.AreEqual(1f, multiplier, 0.01f, "null 소스는 배율 1.0을 반환해야 합니다."); + } + + [Test] + public void ResolveThreatMultiplier_NoModifiers_Returns1() + { + var go = new GameObject("Test_Caster"); + try + { + float multiplier = ThreatUtility.ResolveThreatMultiplier(go); + // ThreatController, PassiveRuntimeController 없음 → 모두 1.0 + Assert.AreEqual(1f, multiplier, 0.01f, "배율이 없으면 1.0을 반환해야 합니다."); + } + finally + { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ResolveThreatMultiplier_WithThreatController_ReturnsCorrectValue() + { + var go = new GameObject("Test_Caster"); + try + { + var threatCtrl = go.AddComponent(); + threatCtrl.ApplyThreatMultiplier(2.5f, 999f); + + float multiplier = ThreatUtility.ResolveThreatMultiplier(go); + // 기본 1.0 × 런타임 2.5 = 2.5 + Assert.AreEqual(2.5f, multiplier, 0.1f, "ThreatController 배율이 반영되어야 합니다."); + } + finally + { + Object.DestroyImmediate(go); + } + } + + // ================================================================ + // 위협 생성 공식 검증 (로직만) + // ================================================================ + + [Test] + public void ThreatFormula_FlatPlusPercent_CalculatesCorrectly() + { + // 위협 공식: flatThreatAmount + (actualHeal × threatPercent) + float flatThreat = 5f; + float actualHeal = 80f; + float threatPercent = 0.5f; + + float expected = flatThreat + (actualHeal * threatPercent); + // 5 + (80 × 0.5) = 5 + 40 = 45 + Assert.AreEqual(45f, expected, 0.01f, "위협 공식이 올바르게 계산되어야 합니다."); + } + + [Test] + public void ThreatFormula_ZeroFlat_OnlyPercent() + { + float flatThreat = 0f; + float actualHeal = 100f; + float threatPercent = 0.3f; + + float expected = flatThreat + (actualHeal * threatPercent); + // 0 + (100 × 0.3) = 30 + Assert.AreEqual(30f, expected, 0.01f, "고정 위협이 0이면 비율만 적용되어야 합니다."); + } + + [Test] + public void ThreatFormula_ZeroPercent_OnlyFlat() + { + float flatThreat = 10f; + float actualHeal = 100f; + float threatPercent = 0f; + + float expected = flatThreat + (actualHeal * threatPercent); + // 10 + (100 × 0) = 10 + Assert.AreEqual(10f, expected, 0.01f, "비율이 0이면 고정 위협만 적용되어야 합니다."); + } + + [Test] + public void ThreatFormula_BothZero_NoThreat() + { + float flatThreat = 0f; + float actualHeal = 100f; + float threatPercent = 0f; + + float expected = flatThreat + (actualHeal * threatPercent); + Assert.AreEqual(0f, expected, 0.01f, "고정 위협과 비율이 모두 0이면 위협이 없어야 합니다."); + } + + [Test] + public void ThreatFormula_FullMultiplierChain_AppliesCorrectly() + { + // 기본 위협 20, ThreatController 배율 3.0 + float baseThreat = 20f; + float expectedFinal = baseThreat * 3.0f; + // 20 × 3.0 = 60 + Assert.AreEqual(60f, expectedFinal, 0.01f, "배율 체인이 올바르게 적용되어야 합니다."); + } + + [Test] + public void ThreatFormula_LargeHeal_ScalesCorrectly() + { + // 대량 힐이 대량 위협을 생성하는지 확인 + float flatThreat = 5f; + float actualHeal = 500f; + float threatPercent = 0.5f; + + float expected = flatThreat + (actualHeal * threatPercent); + // 5 + 250 = 255 + Assert.AreEqual(255f, expected, 0.01f, "대량 힐이 비례하여 대량 위협을 생성해야 합니다."); + } + } +} diff --git a/Assets/_Game/Tests/Editor/SupportThreatTests.cs.meta b/Assets/_Game/Tests/Editor/SupportThreatTests.cs.meta new file mode 100644 index 00000000..59e83216 --- /dev/null +++ b/Assets/_Game/Tests/Editor/SupportThreatTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da36c3904d7f2894c85aafdb2cb9dc7e \ No newline at end of file diff --git a/Colosseum.slnx b/Colosseum.slnx index 78ed4fec..3b6d1fe2 100644 --- a/Colosseum.slnx +++ b/Colosseum.slnx @@ -6,4 +6,5 @@ +