feat: 보호막 타입 분리 및 드로그 시그니처 전조 정리
- 보호막을 단일 수치에서 타입별 독립 인스턴스 구조로 리팩터링하고 같은 타입만 갱신되도록 정리 - 플레이어/보스 보호막 상태를 이상상태와 연동해 HUD 및 보스 UI에서 타입별로 식별 가능하게 보강 - 드로그 집행 개시 전조를 집행 준비 이상상태 기반으로 재구성하고 관련 데이터와 보스 컨텍스트를 정리 - 전투 밸런스 계측기와 디버그 메뉴를 추가해 피해, 치유, 보호막, 위협, 패턴 사용량 측정 경로를 마련 - 테스트용 보호막 A/B와 시그니처 전조 자산을 추가하고 기본 포트 7777 원복 후 빌드 및 런타임 검증을 완료
This commit is contained in:
@@ -6051,6 +6051,8 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: Colosseum.Game::Colosseum.UI.PlayerHUD
|
||||
healthBar: {fileID: 281797463}
|
||||
manaBar: {fileID: 1237841695}
|
||||
abnormalitySummaryText: {fileID: 0}
|
||||
autoCreateAbnormalitySummary: 1
|
||||
autoFindPlayer: 1
|
||||
--- !u!4 &1171449866 stripped
|
||||
Transform:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
|
||||
m_Name: Data_Abnormality_Common_보호막
|
||||
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
|
||||
abnormalityName: 보호막
|
||||
icon: {fileID: 0}
|
||||
duration: 0
|
||||
level: 1
|
||||
isDebuff: 0
|
||||
showInUI: 1
|
||||
isShieldState: 1
|
||||
ignoreHitReaction: 0
|
||||
loopingVfxPrefab: {fileID: 0}
|
||||
loopingVfxOffset: {x: 0, y: 0, z: 0}
|
||||
loopingVfxScaleMultiplier: 1
|
||||
parentLoopingVfxToTarget: 1
|
||||
statModifiers: []
|
||||
periodicInterval: 0
|
||||
periodicValue: 0
|
||||
controlType: 0
|
||||
slowMultiplier: 0.5
|
||||
incomingDamageMultiplier: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 228bf5f3997e4cb582d5fcc66b8b93dc
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
|
||||
m_Name: Data_Abnormality_Drog_집행준비
|
||||
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
|
||||
abnormalityName: 집행 준비
|
||||
icon: {fileID: 0}
|
||||
duration: 0
|
||||
level: 1
|
||||
isDebuff: 0
|
||||
showInUI: 0
|
||||
ignoreHitReaction: 0
|
||||
loopingVfxPrefab: {fileID: 1800972780968652, guid: 205d983549fba2a47a7808abf228f4be, type: 3}
|
||||
loopingVfxOffset: {x: 0, y: 3.2, z: 0}
|
||||
loopingVfxScaleMultiplier: 6
|
||||
parentLoopingVfxToTarget: 0
|
||||
statModifiers: []
|
||||
periodicInterval: 0
|
||||
periodicValue: 0
|
||||
controlType: 0
|
||||
slowMultiplier: 0.5
|
||||
incomingDamageMultiplier: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb1a782e44ff4dc19fd8b3c633360752
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,32 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
|
||||
m_Name: Data_Abnormality_Test_보호막A
|
||||
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
|
||||
abnormalityName: 보호막 A
|
||||
icon: {fileID: 0}
|
||||
duration: 0
|
||||
level: 1
|
||||
isDebuff: 0
|
||||
showInUI: 1
|
||||
isShieldState: 1
|
||||
ignoreHitReaction: 0
|
||||
loopingVfxPrefab: {fileID: 0}
|
||||
loopingVfxOffset: {x: 0, y: 0, z: 0}
|
||||
loopingVfxScaleMultiplier: 1
|
||||
parentLoopingVfxToTarget: 1
|
||||
statModifiers: []
|
||||
periodicInterval: 0
|
||||
periodicValue: 0
|
||||
controlType: 0
|
||||
slowMultiplier: 0.5
|
||||
incomingDamageMultiplier: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d4336ef1eec3e8e479efd9bc67334534
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,32 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
|
||||
m_Name: Data_Abnormality_Test_보호막B
|
||||
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
|
||||
abnormalityName: 보호막 B
|
||||
icon: {fileID: 0}
|
||||
duration: 0
|
||||
level: 1
|
||||
isDebuff: 0
|
||||
showInUI: 1
|
||||
isShieldState: 1
|
||||
ignoreHitReaction: 0
|
||||
loopingVfxPrefab: {fileID: 0}
|
||||
loopingVfxOffset: {x: 0, y: 0, z: 0}
|
||||
loopingVfxScaleMultiplier: 1
|
||||
parentLoopingVfxToTarget: 1
|
||||
statModifiers: []
|
||||
periodicInterval: 0
|
||||
periodicValue: 0
|
||||
controlType: 0
|
||||
slowMultiplier: 0.5
|
||||
incomingDamageMultiplier: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d33ac1a44c7874d4eb24fce2642521fd
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -19,5 +19,5 @@ MonoBehaviour:
|
||||
Duration: 0
|
||||
- Type: 1
|
||||
Skill: {fileID: 0}
|
||||
Duration: 6.5
|
||||
Duration: 0
|
||||
cooldown: 45
|
||||
|
||||
@@ -15,9 +15,9 @@ MonoBehaviour:
|
||||
skillName: "\uC9D1\uD589\uAC1C\uC2DC"
|
||||
description: "\uB4DC\uB85C\uADF8\uAC00 \uD798\uC744 \uB04C\uC5B4\uBAA8\uC73C\uBA70 \uC9D1\uD589\uC744 \uC900\uBE44\uD569\uB2C8\uB2E4."
|
||||
icon: {fileID: 0}
|
||||
skillClip: {fileID: -5764696784021583549, guid: 5eaeca917bbeb494eb14ad0e0552c42f, type: 3}
|
||||
endClip: {fileID: 0}
|
||||
animationSpeed: 1
|
||||
skillClip: {fileID: 1196400477972205583, guid: 330fdc27ae77eab44a6cfa040fa46036, type: 3}
|
||||
endClip: {fileID: -473667693787628719, guid: 580fc907eee299f43a914da368c7a639, type: 3}
|
||||
animationSpeed: 0.5
|
||||
useRootMotion: 0
|
||||
ignoreRootMotionY: 0
|
||||
jumpToTarget: 0
|
||||
@@ -26,4 +26,6 @@ MonoBehaviour:
|
||||
blockOtherSkillsWhileCasting: 1
|
||||
cooldown: 0
|
||||
manaCost: 0
|
||||
castStartEffects:
|
||||
- {fileID: 11400000, guid: 032be692478542b2b7eae48b2a5b29c1, type: 2}
|
||||
effects: []
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bf750718c64c4bd48af905d2927351de, type: 3}
|
||||
m_Name: "Data_SkillEffect_Drog_\uC9D1\uD589\uAC1C\uC2DC_0_\uC9D1\uD589\uC900\uBE44"
|
||||
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.AbnormalityEffect
|
||||
targetType: 0
|
||||
targetTeam: 0
|
||||
areaCenter: 0
|
||||
areaShape: 0
|
||||
targetLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
areaRadius: 3
|
||||
fanOriginDistance: 1
|
||||
fanRadius: 3
|
||||
fanHalfAngle: 45
|
||||
abnormalityData: {fileID: 11400000, guid: fb1a782e44ff4dc19fd8b3c633360752, type: 2}
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 032be692478542b2b7eae48b2a5b29c1
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -27,3 +27,4 @@ MonoBehaviour:
|
||||
baseShield: 28
|
||||
shieldScaling: 0.8
|
||||
duration: 5
|
||||
shieldStateAbnormality: {fileID: 11400000, guid: 228bf5f3997e4cb582d5fcc66b8b93dc, type: 2}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2203,6 +2203,7 @@ MonoBehaviour:
|
||||
phase3SlamInterval: 2
|
||||
signatureMinPhase: 2
|
||||
signatureRequiredDamageRatio: 0.1
|
||||
signatureTelegraphAbnormality: {fileID: 11400000, guid: fb1a782e44ff4dc19fd8b3c633360752, type: 2}
|
||||
signatureSuccessStaggerDuration: 2
|
||||
signatureFailureAbnormality: {fileID: 11400000, guid: bc74f1485ad140c28cc14b821e22c127, type: 2}
|
||||
signatureFailureDamage: 40
|
||||
|
||||
@@ -68,9 +68,26 @@ namespace Colosseum.Abnormalities
|
||||
[Tooltip("플레이어 HUD의 이상상태 UI에 표시할지 여부")]
|
||||
public bool showInUI = true;
|
||||
|
||||
[Tooltip("보호막 계열 상태인지 여부 (보호막 인스턴스 동기화용)")]
|
||||
public bool isShieldState = false;
|
||||
|
||||
[Tooltip("활성 중에는 일반 피격 반응(경직, 넉백, 다운)을 무시할지 여부")]
|
||||
public bool ignoreHitReaction = false;
|
||||
|
||||
[Header("시각 효과")]
|
||||
[Tooltip("이상 상태가 유지되는 동안 대상에 붙일 루핑 VFX 프리팹")]
|
||||
public GameObject loopingVfxPrefab;
|
||||
|
||||
[Tooltip("루핑 VFX 위치 보정값")]
|
||||
public Vector3 loopingVfxOffset = Vector3.zero;
|
||||
|
||||
[Tooltip("루핑 VFX 스케일 배율")]
|
||||
[Min(0.01f)]
|
||||
public float loopingVfxScaleMultiplier = 1f;
|
||||
|
||||
[Tooltip("루핑 VFX를 대상의 자식으로 붙일지 여부")]
|
||||
public bool parentLoopingVfxToTarget = true;
|
||||
|
||||
[Header("스탯 수정자")]
|
||||
[Tooltip("스탯에 적용할 수정자 목록")]
|
||||
public List<AbnormalityStatModifier> statModifiers = new List<AbnormalityStatModifier>();
|
||||
@@ -110,6 +127,11 @@ namespace Colosseum.Abnormalities
|
||||
/// </summary>
|
||||
public bool HasControlEffect => controlType != ControlType.None;
|
||||
|
||||
/// <summary>
|
||||
/// 유지형 루핑 VFX가 있는지 확인
|
||||
/// </summary>
|
||||
public bool HasLoopingVfx => loopingVfxPrefab != null;
|
||||
|
||||
/// <summary>
|
||||
/// 받는 피해 배율 변경 여부
|
||||
/// </summary>
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace Colosseum.Abnormalities
|
||||
|
||||
// 활성화된 이상 상태 목록
|
||||
private readonly List<ActiveAbnormality> activeAbnormalities = new List<ActiveAbnormality>();
|
||||
private readonly Dictionary<int, GameObject> abnormalityVisualInstances = new Dictionary<int, GameObject>();
|
||||
|
||||
// 제어 효과 상태
|
||||
private int stunCount;
|
||||
@@ -150,6 +151,8 @@ namespace Colosseum.Abnormalities
|
||||
{
|
||||
SyncControlEffects();
|
||||
}
|
||||
|
||||
RefreshAbnormalityVisuals();
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
@@ -165,6 +168,8 @@ namespace Colosseum.Abnormalities
|
||||
{
|
||||
networkController.OnDeathStateChanged -= HandleDeathStateChanged;
|
||||
}
|
||||
|
||||
ClearAllAbnormalityVisuals();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -255,6 +260,7 @@ namespace Colosseum.Abnormalities
|
||||
ApplyControlEffect(data);
|
||||
RecalculateIncomingDamageMultiplier();
|
||||
SyncAbnormalityAdd(newAbnormality, source);
|
||||
RefreshAbnormalityVisuals();
|
||||
|
||||
OnAbnormalityAdded?.Invoke(newAbnormality);
|
||||
OnAbnormalitiesChanged?.Invoke();
|
||||
@@ -301,6 +307,7 @@ namespace Colosseum.Abnormalities
|
||||
RecalculateIncomingDamageMultiplier();
|
||||
SyncAbnormalityRemove(abnormality);
|
||||
activeAbnormalities.Remove(abnormality);
|
||||
RefreshAbnormalityVisuals();
|
||||
|
||||
OnAbnormalityRemoved?.Invoke(abnormality);
|
||||
OnAbnormalitiesChanged?.Invoke();
|
||||
@@ -593,6 +600,7 @@ namespace Colosseum.Abnormalities
|
||||
|
||||
private void OnSyncedAbnormalitiesChanged(NetworkListEvent<AbnormalitySyncData> changeEvent)
|
||||
{
|
||||
RefreshAbnormalityVisuals();
|
||||
OnAbnormalitiesChanged?.Invoke();
|
||||
}
|
||||
|
||||
@@ -651,6 +659,111 @@ namespace Colosseum.Abnormalities
|
||||
Debug.Log($"[Abnormality] Cleared all abnormalities on death: {gameObject.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 활성 이상상태 기준으로 루핑 VFX를 동기화합니다.
|
||||
/// </summary>
|
||||
private void RefreshAbnormalityVisuals()
|
||||
{
|
||||
HashSet<int> desiredAbnormalityIds = new HashSet<int>();
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
for (int i = 0; i < activeAbnormalities.Count; i++)
|
||||
{
|
||||
ActiveAbnormality abnormality = activeAbnormalities[i];
|
||||
if (abnormality?.Data == null || !abnormality.Data.HasLoopingVfx)
|
||||
continue;
|
||||
|
||||
int abnormalityId = abnormality.Data.GetInstanceID();
|
||||
desiredAbnormalityIds.Add(abnormalityId);
|
||||
|
||||
if (!abnormalityVisualInstances.ContainsKey(abnormalityId) || abnormalityVisualInstances[abnormalityId] == null)
|
||||
{
|
||||
abnormalityVisualInstances[abnormalityId] = CreateLoopingVfxInstance(abnormality.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < syncedAbnormalities.Count; i++)
|
||||
{
|
||||
AbnormalitySyncData syncData = syncedAbnormalities[i];
|
||||
AbnormalityData data = FindAbnormalityDataById(syncData.AbnormalityId);
|
||||
if (data == null || !data.HasLoopingVfx)
|
||||
continue;
|
||||
|
||||
desiredAbnormalityIds.Add(syncData.AbnormalityId);
|
||||
|
||||
if (!abnormalityVisualInstances.ContainsKey(syncData.AbnormalityId) || abnormalityVisualInstances[syncData.AbnormalityId] == null)
|
||||
{
|
||||
abnormalityVisualInstances[syncData.AbnormalityId] = CreateLoopingVfxInstance(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<int> removeIds = new List<int>();
|
||||
foreach (KeyValuePair<int, GameObject> pair in abnormalityVisualInstances)
|
||||
{
|
||||
if (desiredAbnormalityIds.Contains(pair.Key))
|
||||
continue;
|
||||
|
||||
if (pair.Value != null)
|
||||
Destroy(pair.Value);
|
||||
|
||||
removeIds.Add(pair.Key);
|
||||
}
|
||||
|
||||
for (int i = 0; i < removeIds.Count; i++)
|
||||
{
|
||||
abnormalityVisualInstances.Remove(removeIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 루핑 VFX 인스턴스를 생성합니다.
|
||||
/// </summary>
|
||||
private GameObject CreateLoopingVfxInstance(AbnormalityData data)
|
||||
{
|
||||
if (data == null || data.loopingVfxPrefab == null)
|
||||
return null;
|
||||
|
||||
Transform parent = data.parentLoopingVfxToTarget ? transform : null;
|
||||
GameObject instance = Instantiate(data.loopingVfxPrefab, transform.position + data.loopingVfxOffset, Quaternion.identity, parent);
|
||||
instance.transform.localScale *= data.loopingVfxScaleMultiplier;
|
||||
|
||||
if (data.parentLoopingVfxToTarget)
|
||||
instance.transform.localPosition = data.loopingVfxOffset;
|
||||
else
|
||||
instance.transform.position = transform.position + data.loopingVfxOffset;
|
||||
|
||||
ParticleSystem[] particleSystems = instance.GetComponentsInChildren<ParticleSystem>(true);
|
||||
for (int i = 0; i < particleSystems.Length; i++)
|
||||
{
|
||||
ParticleSystem particleSystem = particleSystems[i];
|
||||
var main = particleSystem.main;
|
||||
main.loop = true;
|
||||
main.stopAction = ParticleSystemStopAction.None;
|
||||
particleSystem.Clear(true);
|
||||
particleSystem.Play(true);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 관리 중인 루핑 VFX를 모두 제거합니다.
|
||||
/// </summary>
|
||||
private void ClearAllAbnormalityVisuals()
|
||||
{
|
||||
foreach (KeyValuePair<int, GameObject> pair in abnormalityVisualInstances)
|
||||
{
|
||||
if (pair.Value != null)
|
||||
Destroy(pair.Value);
|
||||
}
|
||||
|
||||
abnormalityVisualInstances.Clear();
|
||||
}
|
||||
|
||||
private void TryCancelCurrentSkill(SkillCancelReason reason, string sourceName)
|
||||
{
|
||||
if (!IsServer || skillController == null || !skillController.IsPlayingAnimation)
|
||||
@@ -686,6 +799,9 @@ namespace Colosseum.Abnormalities
|
||||
/// </summary>
|
||||
public bool HasAbnormality(AbnormalityData data)
|
||||
{
|
||||
if (data == null)
|
||||
return false;
|
||||
|
||||
return activeAbnormalities.Exists(a => a.Data.abnormalityName == data.abnormalityName);
|
||||
}
|
||||
}
|
||||
|
||||
332
Assets/_Game/Scripts/Combat/CombatBalanceTracker.cs
Normal file
332
Assets/_Game/Scripts/Combat/CombatBalanceTracker.cs
Normal file
@@ -0,0 +1,332 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Enemy;
|
||||
using Colosseum.Skills;
|
||||
|
||||
namespace Colosseum.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 전투 밸런싱 검증을 위한 런타임 계측기입니다.
|
||||
/// 플레이어/보스 기준으로 대미지, 치유, 보호막, 위협, 패턴 사용량을 누적합니다.
|
||||
/// </summary>
|
||||
public static class CombatBalanceTracker
|
||||
{
|
||||
private sealed class ActorMetrics
|
||||
{
|
||||
public string label;
|
||||
public float totalDamageDealt;
|
||||
public float bossDamageDealt;
|
||||
public float damageTaken;
|
||||
public float healDone;
|
||||
public float healReceived;
|
||||
public float shieldApplied;
|
||||
public float shieldReceived;
|
||||
public float threatGenerated;
|
||||
public readonly Dictionary<string, float> damageBySkill = new Dictionary<string, float>();
|
||||
public readonly Dictionary<string, float> healBySkill = new Dictionary<string, float>();
|
||||
public readonly Dictionary<string, float> shieldBySkill = new Dictionary<string, float>();
|
||||
public readonly Dictionary<string, float> threatBySkill = new Dictionary<string, float>();
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, ActorMetrics> actorMetrics = new Dictionary<string, ActorMetrics>();
|
||||
private static readonly Dictionary<string, int> bossPatternCounts = new Dictionary<string, int>();
|
||||
private static readonly Dictionary<string, int> bossEventCounts = new Dictionary<string, int>();
|
||||
private static float combatStartTime = -1f;
|
||||
private static float lastEventTime = -1f;
|
||||
|
||||
/// <summary>
|
||||
/// 누적된 전투 계측 데이터를 초기화합니다.
|
||||
/// </summary>
|
||||
public static void Reset()
|
||||
{
|
||||
actorMetrics.Clear();
|
||||
bossPatternCounts.Clear();
|
||||
bossEventCounts.Clear();
|
||||
combatStartTime = -1f;
|
||||
lastEventTime = -1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실제 적용된 대미지를 기록합니다.
|
||||
/// </summary>
|
||||
public static void RecordDamage(GameObject source, GameObject target, float actualDamage)
|
||||
{
|
||||
if (actualDamage <= 0f || target == null)
|
||||
return;
|
||||
|
||||
MarkCombatEvent();
|
||||
|
||||
ActorMetrics targetMetrics = GetMetrics(target);
|
||||
targetMetrics.damageTaken += actualDamage;
|
||||
|
||||
if (source == null)
|
||||
return;
|
||||
|
||||
ActorMetrics sourceMetrics = GetMetrics(source);
|
||||
sourceMetrics.totalDamageDealt += actualDamage;
|
||||
|
||||
if (target.GetComponent<BossEnemy>() != null || target.GetComponentInParent<BossEnemy>() != null)
|
||||
{
|
||||
sourceMetrics.bossDamageDealt += actualDamage;
|
||||
}
|
||||
|
||||
AddSkillValue(sourceMetrics.damageBySkill, ResolveSkillLabel(source), actualDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실제 적용된 회복량을 기록합니다.
|
||||
/// </summary>
|
||||
public static void RecordHeal(GameObject source, GameObject target, float actualHeal)
|
||||
{
|
||||
if (actualHeal <= 0f || target == null)
|
||||
return;
|
||||
|
||||
MarkCombatEvent();
|
||||
|
||||
ActorMetrics targetMetrics = GetMetrics(target);
|
||||
targetMetrics.healReceived += actualHeal;
|
||||
|
||||
if (source == null)
|
||||
return;
|
||||
|
||||
ActorMetrics sourceMetrics = GetMetrics(source);
|
||||
sourceMetrics.healDone += actualHeal;
|
||||
AddSkillValue(sourceMetrics.healBySkill, ResolveSkillLabel(source), actualHeal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실제 적용된 보호막 수치를 기록합니다.
|
||||
/// </summary>
|
||||
public static void RecordShield(GameObject source, GameObject target, float actualShield)
|
||||
{
|
||||
if (actualShield <= 0f || target == null)
|
||||
return;
|
||||
|
||||
MarkCombatEvent();
|
||||
|
||||
ActorMetrics targetMetrics = GetMetrics(target);
|
||||
targetMetrics.shieldReceived += actualShield;
|
||||
|
||||
if (source == null)
|
||||
return;
|
||||
|
||||
ActorMetrics sourceMetrics = GetMetrics(source);
|
||||
sourceMetrics.shieldApplied += actualShield;
|
||||
AddSkillValue(sourceMetrics.shieldBySkill, ResolveSkillLabel(source), actualShield);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실제 적용된 위협 증가량을 기록합니다.
|
||||
/// </summary>
|
||||
public static void RecordThreat(GameObject source, float threatAmount)
|
||||
{
|
||||
if (threatAmount <= 0f || source == null)
|
||||
return;
|
||||
|
||||
MarkCombatEvent();
|
||||
|
||||
ActorMetrics sourceMetrics = GetMetrics(source);
|
||||
sourceMetrics.threatGenerated += threatAmount;
|
||||
AddSkillValue(sourceMetrics.threatBySkill, ResolveSkillLabel(source), threatAmount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 패턴 사용 횟수를 기록합니다.
|
||||
/// </summary>
|
||||
public static void RecordBossPattern(string patternName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(patternName))
|
||||
return;
|
||||
|
||||
MarkCombatEvent();
|
||||
AddCount(bossPatternCounts, patternName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 시그니처 성공/실패 같은 보스 전투 이벤트를 기록합니다.
|
||||
/// </summary>
|
||||
public static void RecordBossEvent(string eventName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventName))
|
||||
return;
|
||||
|
||||
MarkCombatEvent();
|
||||
AddCount(bossEventCounts, eventName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 누적 계측 데이터를 보기 좋은 문자열로 반환합니다.
|
||||
/// </summary>
|
||||
public static string BuildSummary()
|
||||
{
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.Append("[Balance] 전투 요약");
|
||||
|
||||
if (combatStartTime >= 0f && lastEventTime >= combatStartTime)
|
||||
{
|
||||
builder.Append(" | Duration=");
|
||||
builder.Append((lastEventTime - combatStartTime).ToString("0.00"));
|
||||
builder.Append("s");
|
||||
}
|
||||
|
||||
if (bossPatternCounts.Count > 0)
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.Append("보스 패턴: ");
|
||||
AppendCountSummary(builder, bossPatternCounts);
|
||||
}
|
||||
|
||||
if (bossEventCounts.Count > 0)
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.Append("보스 이벤트: ");
|
||||
AppendCountSummary(builder, bossEventCounts);
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, ActorMetrics> pair in actorMetrics)
|
||||
{
|
||||
ActorMetrics metrics = pair.Value;
|
||||
builder.AppendLine();
|
||||
builder.Append("- ");
|
||||
builder.Append(metrics.label);
|
||||
builder.Append(" | BossDmg=");
|
||||
builder.Append(metrics.bossDamageDealt.ToString("0.##"));
|
||||
builder.Append(" | TotalDmg=");
|
||||
builder.Append(metrics.totalDamageDealt.ToString("0.##"));
|
||||
builder.Append(" | Taken=");
|
||||
builder.Append(metrics.damageTaken.ToString("0.##"));
|
||||
builder.Append(" | Heal=");
|
||||
builder.Append(metrics.healDone.ToString("0.##"));
|
||||
builder.Append(" | HealRecv=");
|
||||
builder.Append(metrics.healReceived.ToString("0.##"));
|
||||
builder.Append(" | Shield=");
|
||||
builder.Append(metrics.shieldApplied.ToString("0.##"));
|
||||
builder.Append(" | ShieldRecv=");
|
||||
builder.Append(metrics.shieldReceived.ToString("0.##"));
|
||||
builder.Append(" | Threat=");
|
||||
builder.Append(metrics.threatGenerated.ToString("0.##"));
|
||||
|
||||
AppendSkillBreakdown(builder, "DmgBySkill", metrics.damageBySkill);
|
||||
AppendSkillBreakdown(builder, "HealBySkill", metrics.healBySkill);
|
||||
AppendSkillBreakdown(builder, "ShieldBySkill", metrics.shieldBySkill);
|
||||
AppendSkillBreakdown(builder, "ThreatBySkill", metrics.threatBySkill);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void MarkCombatEvent()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
return;
|
||||
|
||||
float now = Time.time;
|
||||
if (combatStartTime < 0f)
|
||||
combatStartTime = now;
|
||||
|
||||
lastEventTime = now;
|
||||
}
|
||||
|
||||
private static ActorMetrics GetMetrics(GameObject actor)
|
||||
{
|
||||
string actorLabel = ResolveActorLabel(actor);
|
||||
if (!actorMetrics.TryGetValue(actorLabel, out ActorMetrics metrics))
|
||||
{
|
||||
metrics = new ActorMetrics
|
||||
{
|
||||
label = actorLabel,
|
||||
};
|
||||
|
||||
actorMetrics.Add(actorLabel, metrics);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private static string ResolveActorLabel(GameObject actor)
|
||||
{
|
||||
if (actor == null)
|
||||
return "Unknown";
|
||||
|
||||
NetworkObject networkObject = actor.GetComponent<NetworkObject>() ?? actor.GetComponentInParent<NetworkObject>();
|
||||
if (networkObject != null)
|
||||
{
|
||||
string roleLabel = actor.GetComponent<BossEnemy>() != null || actor.GetComponentInParent<BossEnemy>() != null
|
||||
? "Boss"
|
||||
: "Actor";
|
||||
|
||||
return $"{roleLabel}:{actor.name}(Owner={networkObject.OwnerClientId})";
|
||||
}
|
||||
|
||||
return actor.name;
|
||||
}
|
||||
|
||||
private static string ResolveSkillLabel(GameObject source)
|
||||
{
|
||||
if (source == null)
|
||||
return "Unknown";
|
||||
|
||||
SkillController skillController = source.GetComponent<SkillController>() ?? source.GetComponentInParent<SkillController>();
|
||||
if (skillController != null && skillController.CurrentSkill != null)
|
||||
return skillController.CurrentSkill.SkillName;
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static void AddSkillValue(Dictionary<string, float> dictionary, string key, float value)
|
||||
{
|
||||
if (dictionary == null || string.IsNullOrWhiteSpace(key) || value <= 0f)
|
||||
return;
|
||||
|
||||
dictionary.TryGetValue(key, out float currentValue);
|
||||
dictionary[key] = currentValue + value;
|
||||
}
|
||||
|
||||
private static void AddCount(Dictionary<string, int> dictionary, string key)
|
||||
{
|
||||
dictionary.TryGetValue(key, out int currentValue);
|
||||
dictionary[key] = currentValue + 1;
|
||||
}
|
||||
|
||||
private static void AppendCountSummary(StringBuilder builder, Dictionary<string, int> dictionary)
|
||||
{
|
||||
bool first = true;
|
||||
foreach (KeyValuePair<string, int> pair in dictionary)
|
||||
{
|
||||
if (!first)
|
||||
builder.Append(" | ");
|
||||
|
||||
builder.Append(pair.Key);
|
||||
builder.Append('=');
|
||||
builder.Append(pair.Value);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendSkillBreakdown(StringBuilder builder, string label, Dictionary<string, float> dictionary)
|
||||
{
|
||||
if (dictionary == null || dictionary.Count == 0)
|
||||
return;
|
||||
|
||||
builder.Append(" | ");
|
||||
builder.Append(label);
|
||||
builder.Append('=');
|
||||
|
||||
bool first = true;
|
||||
foreach (KeyValuePair<string, float> pair in dictionary)
|
||||
{
|
||||
if (!first)
|
||||
builder.Append(", ");
|
||||
|
||||
builder.Append(pair.Key);
|
||||
builder.Append(':');
|
||||
builder.Append(pair.Value.ToString("0.##"));
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Combat/CombatBalanceTracker.cs.meta
Normal file
2
Assets/_Game/Scripts/Combat/CombatBalanceTracker.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9168649dde02654baf3b890d70d8b74
|
||||
271
Assets/_Game/Scripts/Combat/ShieldAbnormalityUtility.cs
Normal file
271
Assets/_Game/Scripts/Combat/ShieldAbnormalityUtility.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Abnormalities;
|
||||
|
||||
namespace Colosseum.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 개별 보호막 인스턴스입니다.
|
||||
/// 같은 종류의 보호막은 하나로 합쳐지고, 다른 종류의 보호막은 독립적으로 유지됩니다.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public sealed class ActiveShield
|
||||
{
|
||||
[SerializeField] private AbnormalityData shieldType;
|
||||
[SerializeField] private float remainingAmount;
|
||||
[SerializeField] private float remainingDuration;
|
||||
[SerializeField] private bool isPermanent;
|
||||
[SerializeField] private GameObject source;
|
||||
|
||||
public AbnormalityData ShieldType => shieldType;
|
||||
public float RemainingAmount => remainingAmount;
|
||||
public float RemainingDuration => isPermanent ? 0f : remainingDuration;
|
||||
public bool IsPermanent => isPermanent;
|
||||
public bool IsExpired => !isPermanent && remainingDuration <= 0f;
|
||||
public bool IsDepleted => remainingAmount <= 0f;
|
||||
public GameObject Source => source;
|
||||
|
||||
public ActiveShield(AbnormalityData shieldType, float amount, float duration, GameObject source)
|
||||
{
|
||||
this.shieldType = shieldType;
|
||||
remainingAmount = Mathf.Max(0f, amount);
|
||||
isPermanent = duration <= 0f;
|
||||
remainingDuration = isPermanent ? 0f : duration;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 같은 종류 보호막이 다시 적용되었을 때 양을 더하고 지속시간을 갱신합니다.
|
||||
/// </summary>
|
||||
public float Add(float amount, float duration, GameObject source)
|
||||
{
|
||||
float appliedAmount = Mathf.Max(0f, amount);
|
||||
remainingAmount += appliedAmount;
|
||||
|
||||
if (duration <= 0f)
|
||||
{
|
||||
isPermanent = true;
|
||||
remainingDuration = 0f;
|
||||
}
|
||||
else if (!isPermanent)
|
||||
{
|
||||
remainingDuration = duration;
|
||||
}
|
||||
|
||||
if (source != null)
|
||||
{
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
return appliedAmount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지속시간을 감소시킵니다.
|
||||
/// </summary>
|
||||
public bool Tick(float deltaTime)
|
||||
{
|
||||
if (isPermanent || deltaTime <= 0f)
|
||||
return false;
|
||||
|
||||
remainingDuration = Mathf.Max(0f, remainingDuration - deltaTime);
|
||||
return IsExpired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 들어오는 피해를 흡수합니다.
|
||||
/// </summary>
|
||||
public float Consume(float incomingDamage)
|
||||
{
|
||||
if (incomingDamage <= 0f || remainingAmount <= 0f)
|
||||
return 0f;
|
||||
|
||||
float absorbed = Mathf.Min(remainingAmount, incomingDamage);
|
||||
remainingAmount = Mathf.Max(0f, remainingAmount - absorbed);
|
||||
return absorbed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대상이 가진 보호막 인스턴스를 관리합니다.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public sealed class ShieldCollection
|
||||
{
|
||||
private readonly List<ActiveShield> activeShields = new List<ActiveShield>();
|
||||
|
||||
public IReadOnlyList<ActiveShield> ActiveShields => activeShields;
|
||||
|
||||
public float TotalAmount
|
||||
{
|
||||
get
|
||||
{
|
||||
float total = 0f;
|
||||
for (int i = 0; i < activeShields.Count; i++)
|
||||
{
|
||||
total += activeShields[i].RemainingAmount;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보호막을 적용합니다. 같은 종류는 자기 자신만 합산 및 갱신합니다.
|
||||
/// </summary>
|
||||
public float ApplyShield(AbnormalityData shieldType, float amount, float duration, GameObject source)
|
||||
{
|
||||
if (amount <= 0f)
|
||||
return 0f;
|
||||
|
||||
ActiveShield existingShield = FindShield(shieldType);
|
||||
if (existingShield != null)
|
||||
{
|
||||
return existingShield.Add(amount, duration, source);
|
||||
}
|
||||
|
||||
activeShields.Add(new ActiveShield(shieldType, amount, duration, source));
|
||||
return amount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보호막이 적용된 순서대로 피해를 흡수하고 남은 피해를 반환합니다.
|
||||
/// </summary>
|
||||
public float ConsumeDamage(float incomingDamage)
|
||||
{
|
||||
if (incomingDamage <= 0f || activeShields.Count == 0)
|
||||
return incomingDamage;
|
||||
|
||||
float remainingDamage = incomingDamage;
|
||||
for (int i = 0; i < activeShields.Count && remainingDamage > 0f; i++)
|
||||
{
|
||||
remainingDamage -= activeShields[i].Consume(remainingDamage);
|
||||
}
|
||||
|
||||
CleanupInactiveShields();
|
||||
return Mathf.Max(0f, remainingDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지속시간 경과를 처리합니다.
|
||||
/// </summary>
|
||||
public bool Tick(float deltaTime)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
for (int i = activeShields.Count - 1; i >= 0; i--)
|
||||
{
|
||||
ActiveShield shield = activeShields[i];
|
||||
bool expired = shield.Tick(deltaTime);
|
||||
if (!expired && !shield.IsDepleted)
|
||||
continue;
|
||||
|
||||
activeShields.RemoveAt(i);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 보호막을 제거합니다.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
activeShields.Clear();
|
||||
}
|
||||
|
||||
private ActiveShield FindShield(AbnormalityData shieldType)
|
||||
{
|
||||
for (int i = 0; i < activeShields.Count; i++)
|
||||
{
|
||||
if (activeShields[i].ShieldType == shieldType)
|
||||
return activeShields[i];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CleanupInactiveShields()
|
||||
{
|
||||
for (int i = activeShields.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!activeShields[i].IsExpired && !activeShields[i].IsDepleted)
|
||||
continue;
|
||||
|
||||
activeShields.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보호막 수치와 보호막 이상상태를 동기화하는 유틸리티입니다.
|
||||
/// </summary>
|
||||
public static class ShieldAbnormalityUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// 활성 보호막 목록에 맞춰 보호막 이상상태를 적용하거나 제거합니다.
|
||||
/// </summary>
|
||||
public static void SyncShieldAbnormalities(
|
||||
AbnormalityManager abnormalityManager,
|
||||
IReadOnlyList<ActiveShield> activeShields,
|
||||
GameObject defaultSource)
|
||||
{
|
||||
if (abnormalityManager == null)
|
||||
return;
|
||||
|
||||
HashSet<AbnormalityData> desiredShieldStates = new HashSet<AbnormalityData>();
|
||||
Dictionary<AbnormalityData, GameObject> shieldSources = new Dictionary<AbnormalityData, GameObject>();
|
||||
|
||||
if (activeShields != null)
|
||||
{
|
||||
for (int i = 0; i < activeShields.Count; i++)
|
||||
{
|
||||
ActiveShield shield = activeShields[i];
|
||||
if (shield == null || shield.RemainingAmount <= 0f || shield.ShieldType == null)
|
||||
continue;
|
||||
|
||||
desiredShieldStates.Add(shield.ShieldType);
|
||||
|
||||
if (!shieldSources.ContainsKey(shield.ShieldType))
|
||||
{
|
||||
shieldSources.Add(shield.ShieldType, shield.Source != null ? shield.Source : defaultSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (AbnormalityData shieldState in desiredShieldStates)
|
||||
{
|
||||
if (abnormalityManager.HasAbnormality(shieldState))
|
||||
continue;
|
||||
|
||||
GameObject source = shieldSources.TryGetValue(shieldState, out GameObject resolvedSource)
|
||||
? resolvedSource
|
||||
: defaultSource;
|
||||
abnormalityManager.ApplyAbnormality(shieldState, source);
|
||||
}
|
||||
|
||||
IReadOnlyList<ActiveAbnormality> activeAbnormalities = abnormalityManager.ActiveAbnormalities;
|
||||
List<AbnormalityData> removeList = new List<AbnormalityData>();
|
||||
|
||||
for (int i = 0; i < activeAbnormalities.Count; i++)
|
||||
{
|
||||
ActiveAbnormality activeAbnormality = activeAbnormalities[i];
|
||||
if (activeAbnormality?.Data == null || !activeAbnormality.Data.isShieldState)
|
||||
continue;
|
||||
|
||||
if (desiredShieldStates.Contains(activeAbnormality.Data))
|
||||
continue;
|
||||
|
||||
removeList.Add(activeAbnormality.Data);
|
||||
}
|
||||
|
||||
for (int i = 0; i < removeList.Count; i++)
|
||||
{
|
||||
abnormalityManager.RemoveAbnormality(removeList[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69ea46fefb9597e49a4ce68c81ccf0ce
|
||||
@@ -7,6 +7,7 @@ using Colosseum.Skills;
|
||||
using Colosseum.Skills.Effects;
|
||||
using Colosseum.UI;
|
||||
using Colosseum.Abnormalities;
|
||||
using Colosseum.Combat;
|
||||
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
@@ -35,6 +36,9 @@ namespace Colosseum.Editor
|
||||
private const string StunAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset";
|
||||
private const string SilenceAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset";
|
||||
private const string MarkAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_집행자의낙인.asset";
|
||||
private const string ShieldAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Common_보호막.asset";
|
||||
private const string ShieldTypeAPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Test_보호막A.asset";
|
||||
private const string ShieldTypeBPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Test_보호막B.asset";
|
||||
private const string SkillGemFolderPath = "Assets/_Game/Data/SkillGems";
|
||||
private const string LoadoutPresetFolderPath = "Assets/_Game/Data/Loadouts";
|
||||
private const string CrushGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_파쇄.asset";
|
||||
@@ -222,6 +226,22 @@ namespace Colosseum.Editor
|
||||
Debug.Log($"[Debug] 보스 체력 | Name={boss.name} | HP={boss.CurrentHealth:0.###}/{boss.MaxHealth:0.###}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Reset Combat Balance Metrics")]
|
||||
private static void ResetCombatBalanceMetrics()
|
||||
{
|
||||
CombatBalanceTracker.Reset();
|
||||
Debug.Log("[Debug] 전투 밸런스 계측기를 초기화했습니다.");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Log Combat Balance Summary")]
|
||||
private static void LogCombatBalanceSummary()
|
||||
{
|
||||
string summary = CombatBalanceTracker.BuildSummary()
|
||||
.Replace("\r\n", " || ")
|
||||
.Replace("\n", " || ");
|
||||
Debug.Log(summary);
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Apply Local Stun")]
|
||||
private static void ApplyLocalStun()
|
||||
{
|
||||
@@ -267,6 +287,143 @@ namespace Colosseum.Editor
|
||||
Debug.Log($"[Debug] 보스에게 기절 적용 | Target={abnormalityManager.gameObject.name} | Abnormality={abnormality.abnormalityName}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Apply Boss Shield")]
|
||||
private static void ApplyBossShield()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
BossEnemy bossEnemy = FindBossEnemy();
|
||||
if (bossEnemy == null)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
AbnormalityData shieldAbnormality = AssetDatabase.LoadAssetAtPath<AbnormalityData>(ShieldAbnormalityPath);
|
||||
if (shieldAbnormality == null)
|
||||
{
|
||||
Debug.LogWarning($"[Debug] 보호막 이상상태 에셋을 찾지 못했습니다: {ShieldAbnormalityPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
float appliedShield = bossEnemy.ApplyShield(120f, 8f, shieldAbnormality, bossEnemy.gameObject);
|
||||
Debug.Log($"[Debug] 보스에게 보호막 적용 | Target={bossEnemy.name} | Applied={appliedShield:0.##} | CurrentShield={bossEnemy.Shield:0.##}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Apply Boss Shield Type A")]
|
||||
private static void ApplyBossShieldTypeA()
|
||||
{
|
||||
ApplyBossShieldWithType(ShieldTypeAPath, 100f, 4f);
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Apply Boss Shield Type B")]
|
||||
private static void ApplyBossShieldTypeB()
|
||||
{
|
||||
ApplyBossShieldWithType(ShieldTypeBPath, 150f, 8f);
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Force Boss Phase 2")]
|
||||
private static void ForceBossPhase2()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
BossEnemy bossEnemy = FindBossEnemy();
|
||||
if (bossEnemy == null)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
bossEnemy.ForcePhaseTransition(1);
|
||||
Debug.Log($"[Debug] 보스를 Phase 2로 강제 전환했습니다. | Target={bossEnemy.name}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Force Boss Signature")]
|
||||
private static void ForceBossSignature()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
BossCombatBehaviorContext context = FindBossCombatContext();
|
||||
if (context == null)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 보스 전투 컨텍스트를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.ForceStartSignaturePattern())
|
||||
{
|
||||
Debug.LogWarning("[Debug] 집행 개시를 강제로 시작하지 못했습니다. 이미 실행 중이거나 패턴이 비어 있을 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[Debug] 집행 개시를 강제로 시작했습니다. | Target={context.gameObject.name}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Preview Boss Signature Telegraph")]
|
||||
private static void PreviewBossSignatureTelegraph()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
BossCombatBehaviorContext context = FindBossCombatContext();
|
||||
if (context == null)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 보스 전투 컨텍스트를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.PreviewSignatureTelegraph())
|
||||
{
|
||||
Debug.LogWarning("[Debug] 집행 개시 전조 프리뷰를 시작하지 못했습니다. 이미 다른 스킬이 재생 중일 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[Debug] 집행 개시 전조 프리뷰를 재생했습니다. | Target={context.gameObject.name}");
|
||||
}
|
||||
|
||||
private static void ApplyBossShieldWithType(string assetPath, float amount, float duration)
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
BossEnemy bossEnemy = FindBossEnemy();
|
||||
if (bossEnemy == null)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
AbnormalityData shieldAbnormality = AssetDatabase.LoadAssetAtPath<AbnormalityData>(assetPath);
|
||||
if (shieldAbnormality == null)
|
||||
{
|
||||
Debug.LogWarning($"[Debug] 보호막 이상상태 에셋을 찾지 못했습니다: {assetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
float appliedShield = bossEnemy.ApplyShield(amount, duration, shieldAbnormality, bossEnemy.gameObject);
|
||||
Debug.Log(
|
||||
$"[Debug] 보스에게 타입 보호막 적용 | Target={bossEnemy.name} | ShieldType={shieldAbnormality.abnormalityName} | " +
|
||||
$"Applied={appliedShield:0.##} | CurrentShield={bossEnemy.Shield:0.##} | Duration={duration:0.##}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Log HUD Abnormality Summary")]
|
||||
private static void LogHudAbnormalitySummary()
|
||||
{
|
||||
@@ -286,6 +443,25 @@ namespace Colosseum.Editor
|
||||
Debug.Log($"[Debug] HUD 이상상태 요약 | {playerHud.CurrentAbnormalitySummary}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Log Boss HUD Abnormality Summary")]
|
||||
private static void LogBossHudAbnormalitySummary()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
BossHealthBarUI bossHealthBarUi = Object.FindFirstObjectByType<BossHealthBarUI>();
|
||||
if (bossHealthBarUi == null)
|
||||
{
|
||||
Debug.LogWarning("[Debug] BossHealthBarUI를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[Debug] 보스 HUD 이상상태 요약 | {bossHealthBarUi.CurrentAbnormalitySummary}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colosseum/Debug/Apply Tank Loadout")]
|
||||
private static void ApplyTankLoadout()
|
||||
{
|
||||
@@ -730,16 +906,26 @@ namespace Colosseum.Editor
|
||||
|
||||
private static AbnormalityManager FindBossAbnormalityManager()
|
||||
{
|
||||
BossEnemy activeBoss = BossEnemy.ActiveBoss;
|
||||
if (activeBoss != null)
|
||||
{
|
||||
AbnormalityManager activeManager = activeBoss.GetComponent<AbnormalityManager>();
|
||||
if (activeManager != null)
|
||||
return activeManager;
|
||||
BossEnemy bossEnemy = FindBossEnemy();
|
||||
if (bossEnemy == null)
|
||||
return null;
|
||||
|
||||
return bossEnemy.GetComponent<AbnormalityManager>();
|
||||
}
|
||||
|
||||
BossEnemy bossEnemy = Object.FindFirstObjectByType<BossEnemy>();
|
||||
return bossEnemy != null ? bossEnemy.GetComponent<AbnormalityManager>() : null;
|
||||
private static BossEnemy FindBossEnemy()
|
||||
{
|
||||
BossEnemy activeBoss = BossEnemy.ActiveBoss;
|
||||
if (activeBoss != null)
|
||||
return activeBoss;
|
||||
|
||||
return Object.FindFirstObjectByType<BossEnemy>();
|
||||
}
|
||||
|
||||
private static BossCombatBehaviorContext FindBossCombatContext()
|
||||
{
|
||||
BossEnemy bossEnemy = FindBossEnemy();
|
||||
return bossEnemy != null ? bossEnemy.GetComponent<BossCombatBehaviorContext>() : null;
|
||||
}
|
||||
|
||||
private static void CastLocalSkill(int slotIndex)
|
||||
|
||||
@@ -97,6 +97,9 @@ namespace Colosseum.Enemy
|
||||
[Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] protected float signatureRequiredDamageRatio = 0.1f;
|
||||
|
||||
[Tooltip("시그니처 준비 상태를 나타내는 이상상태")]
|
||||
[SerializeField] protected AbnormalityData signatureTelegraphAbnormality;
|
||||
|
||||
[Tooltip("시그니처 차단 성공 시 보스가 멈추는 시간")]
|
||||
[Min(0f)] [SerializeField] protected float signatureSuccessStaggerDuration = 2f;
|
||||
|
||||
@@ -135,8 +138,11 @@ namespace Colosseum.Enemy
|
||||
protected float nextTargetRefreshTime;
|
||||
protected int meleePatternCounter;
|
||||
protected bool isSignaturePatternActive;
|
||||
protected bool isPreviewingSignatureTelegraph;
|
||||
protected float signatureAccumulatedDamage;
|
||||
protected float signatureRequiredDamage;
|
||||
protected float signatureElapsedTime;
|
||||
protected float signatureTotalDuration;
|
||||
|
||||
/// <summary>
|
||||
/// 전용 컨텍스트 사용 시 BehaviorGraph를 비활성화할지 여부
|
||||
@@ -187,6 +193,14 @@ namespace Colosseum.Enemy
|
||||
/// 시그니처 패턴 진행 여부
|
||||
/// </summary>
|
||||
public bool IsSignaturePatternActive => isSignaturePatternActive;
|
||||
public string SignaturePatternName => isSignaturePatternActive && signaturePattern != null ? signaturePattern.PatternName : string.Empty;
|
||||
public float SignatureAccumulatedDamage => signatureAccumulatedDamage;
|
||||
public float SignatureRequiredDamage => signatureRequiredDamage;
|
||||
public float SignatureBreakProgressNormalized => signatureRequiredDamage > 0f ? Mathf.Clamp01(signatureAccumulatedDamage / signatureRequiredDamage) : 0f;
|
||||
public float SignatureElapsedTime => signatureElapsedTime;
|
||||
public float SignatureTotalDuration => signatureTotalDuration;
|
||||
public float SignatureCastProgressNormalized => signatureTotalDuration > 0f ? Mathf.Clamp01(signatureElapsedTime / signatureTotalDuration) : 0f;
|
||||
public float SignatureRemainingTime => Mathf.Max(0f, signatureTotalDuration - signatureElapsedTime);
|
||||
|
||||
/// <summary>
|
||||
/// 디버그 로그 출력 여부
|
||||
@@ -196,7 +210,7 @@ namespace Colosseum.Enemy
|
||||
/// <summary>
|
||||
/// 기절 등으로 인해 보스 전투 로직을 진행할 수 없는 상태인지 여부
|
||||
/// </summary>
|
||||
public bool IsBehaviorSuppressed => abnormalityManager != null && abnormalityManager.IsStunned;
|
||||
public bool IsBehaviorSuppressed => isPreviewingSignatureTelegraph || (abnormalityManager != null && abnormalityManager.IsStunned);
|
||||
|
||||
/// <summary>
|
||||
/// 현재 보스 패턴 페이즈
|
||||
@@ -509,6 +523,35 @@ namespace Colosseum.Enemy
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 디버그 또는 특수 연출에서 시그니처 패턴을 강제로 시작합니다.
|
||||
/// </summary>
|
||||
public bool ForceStartSignaturePattern(GameObject target = null)
|
||||
{
|
||||
if (!IsServer || signaturePattern == null || activePatternCoroutine != null || isSignaturePatternActive)
|
||||
return false;
|
||||
|
||||
GameObject resolvedTarget = IsValidHostileTarget(target) ? target : ResolvePrimaryTarget();
|
||||
activePatternCoroutine = StartCoroutine(RunSignaturePatternCoroutine(signaturePattern, resolvedTarget));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 네트워크 상태와 무관하게 시그니처 전조 모션만 미리보기로 재생합니다.
|
||||
/// 전조 연출 확인용 디버그 경로입니다.
|
||||
/// </summary>
|
||||
public bool PreviewSignatureTelegraph()
|
||||
{
|
||||
if (signaturePattern == null || skillController == null)
|
||||
return false;
|
||||
|
||||
if (activePatternCoroutine != null || isSignaturePatternActive || isPreviewingSignatureTelegraph)
|
||||
return false;
|
||||
|
||||
StartCoroutine(PreviewSignatureTelegraphCoroutine());
|
||||
return true;
|
||||
}
|
||||
|
||||
protected virtual bool TryStartPrimaryLoopPattern()
|
||||
{
|
||||
if (currentTarget == null)
|
||||
@@ -593,6 +636,7 @@ namespace Colosseum.Enemy
|
||||
|
||||
currentTarget = target;
|
||||
LogDebug(GetType().Name, $"패턴 시작: {pattern.PatternName} / Target={(target != null ? target.name : "None")} / Phase={CurrentPatternPhase}");
|
||||
CombatBalanceTracker.RecordBossPattern(pattern.PatternName);
|
||||
activePatternCoroutine = StartCoroutine(RunPatternCoroutine(pattern, target));
|
||||
}
|
||||
|
||||
@@ -747,6 +791,8 @@ namespace Colosseum.Enemy
|
||||
isSignaturePatternActive = true;
|
||||
signatureAccumulatedDamage = 0f;
|
||||
signatureRequiredDamage = bossEnemy.MaxHealth * signatureRequiredDamageRatio;
|
||||
signatureElapsedTime = 0f;
|
||||
signatureTotalDuration = CalculatePatternDuration(pattern);
|
||||
|
||||
bool interrupted = false;
|
||||
bool completed = true;
|
||||
@@ -777,6 +823,7 @@ namespace Colosseum.Enemy
|
||||
break;
|
||||
}
|
||||
|
||||
signatureElapsedTime += Time.deltaTime;
|
||||
remaining -= Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
@@ -815,6 +862,7 @@ namespace Colosseum.Enemy
|
||||
break;
|
||||
}
|
||||
|
||||
signatureElapsedTime += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
@@ -827,6 +875,7 @@ namespace Colosseum.Enemy
|
||||
skillController?.CancelSkill(SkillCancelReason.Interrupt);
|
||||
UsePatternAction.MarkPatternUsed(gameObject, pattern);
|
||||
LogDebug(GetType().Name, $"시그니처 차단 성공: 누적 피해 {signatureAccumulatedDamage:F1} / 필요 {signatureRequiredDamage:F1}");
|
||||
CombatBalanceTracker.RecordBossEvent("집행 개시 차단 성공");
|
||||
|
||||
if (signatureSuccessStaggerDuration > 0f)
|
||||
{
|
||||
@@ -848,15 +897,110 @@ namespace Colosseum.Enemy
|
||||
{
|
||||
UsePatternAction.MarkPatternUsed(gameObject, pattern);
|
||||
LogDebug(GetType().Name, $"시그니처 실패: 누적 피해 {signatureAccumulatedDamage:F1} / 필요 {signatureRequiredDamage:F1}");
|
||||
CombatBalanceTracker.RecordBossEvent("집행 개시 실패");
|
||||
ExecuteSignatureFailure();
|
||||
}
|
||||
|
||||
if (abnormalityManager != null && signatureTelegraphAbnormality != null)
|
||||
{
|
||||
abnormalityManager.RemoveAbnormality(signatureTelegraphAbnormality);
|
||||
}
|
||||
|
||||
isSignaturePatternActive = false;
|
||||
signatureAccumulatedDamage = 0f;
|
||||
signatureRequiredDamage = 0f;
|
||||
signatureElapsedTime = 0f;
|
||||
signatureTotalDuration = 0f;
|
||||
activePatternCoroutine = null;
|
||||
}
|
||||
|
||||
private IEnumerator PreviewSignatureTelegraphCoroutine()
|
||||
{
|
||||
bool restoreBehaviorGraph = behaviorGraphAgent != null && behaviorGraphAgent.enabled;
|
||||
isPreviewingSignatureTelegraph = true;
|
||||
|
||||
if (restoreBehaviorGraph)
|
||||
{
|
||||
behaviorGraphAgent.enabled = false;
|
||||
}
|
||||
|
||||
StopMovement();
|
||||
|
||||
if (skillController != null && skillController.IsPlayingAnimation)
|
||||
{
|
||||
skillController.CancelSkill(SkillCancelReason.Interrupt);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
bool executed = false;
|
||||
for (int i = 0; i < signaturePattern.Steps.Count; i++)
|
||||
{
|
||||
PatternStep step = signaturePattern.Steps[i];
|
||||
if (step == null || step.Type != PatternStepType.Skill || step.Skill == null)
|
||||
continue;
|
||||
|
||||
executed = skillController.ExecuteSkill(step.Skill);
|
||||
break;
|
||||
}
|
||||
|
||||
if (executed)
|
||||
{
|
||||
while (skillController != null && skillController.IsPlayingAnimation)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (abnormalityManager != null && signatureTelegraphAbnormality != null)
|
||||
{
|
||||
abnormalityManager.RemoveAbnormality(signatureTelegraphAbnormality);
|
||||
}
|
||||
|
||||
if (restoreBehaviorGraph && behaviorGraphAgent != null)
|
||||
{
|
||||
behaviorGraphAgent.enabled = true;
|
||||
}
|
||||
|
||||
isPreviewingSignatureTelegraph = false;
|
||||
}
|
||||
|
||||
private static float CalculatePatternDuration(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null || pattern.Steps == null)
|
||||
return 0f;
|
||||
|
||||
float totalDuration = 0f;
|
||||
for (int i = 0; i < pattern.Steps.Count; i++)
|
||||
{
|
||||
PatternStep step = pattern.Steps[i];
|
||||
if (step == null)
|
||||
continue;
|
||||
|
||||
if (step.Type == PatternStepType.Wait)
|
||||
{
|
||||
totalDuration += Mathf.Max(0f, step.Duration);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.Skill == null)
|
||||
continue;
|
||||
|
||||
AnimationClip skillClip = step.Skill.SkillClip;
|
||||
if (skillClip != null)
|
||||
{
|
||||
float animationSpeed = Mathf.Max(0.01f, step.Skill.AnimationSpeed);
|
||||
totalDuration += skillClip.length / animationSpeed;
|
||||
}
|
||||
|
||||
if (step.Skill.EndClip != null)
|
||||
{
|
||||
totalDuration += step.Skill.EndClip.length;
|
||||
}
|
||||
}
|
||||
|
||||
return totalDuration;
|
||||
}
|
||||
|
||||
private void ExecuteSignatureFailure()
|
||||
{
|
||||
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Text;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
using Colosseum.Abnormalities;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Combat;
|
||||
using Colosseum.Skills;
|
||||
@@ -24,6 +25,8 @@ namespace Colosseum.Enemy
|
||||
[SerializeField] protected Animator animator;
|
||||
[Tooltip("NavMeshAgent 또는 이동 컴포넌트")]
|
||||
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
|
||||
[Tooltip("이상상태 관리자")]
|
||||
[SerializeField] protected AbnormalityManager abnormalityManager;
|
||||
|
||||
[Header("Data")]
|
||||
[SerializeField] protected EnemyData enemyData;
|
||||
@@ -38,11 +41,17 @@ namespace Colosseum.Enemy
|
||||
[Tooltip("현재 타겟보다 이 값 이상 높을 때만 새 타겟으로 전환합니다.")]
|
||||
[Min(0f)] [SerializeField] private float retargetThreshold = 0f;
|
||||
|
||||
[Header("Shield")]
|
||||
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
|
||||
[SerializeField] private AbnormalityData shieldStateAbnormality;
|
||||
|
||||
|
||||
// 네트워크 동기화 변수
|
||||
protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
|
||||
protected NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
|
||||
protected NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
|
||||
protected NetworkVariable<float> currentShield = new NetworkVariable<float>(0f);
|
||||
private readonly ShieldCollection shieldCollection = new ShieldCollection();
|
||||
|
||||
// 플레이어 분리용 (레이어 의존 없이 CharacterController로 식별)
|
||||
private readonly Collider[] overlapBuffer = new Collider[8];
|
||||
@@ -56,9 +65,9 @@ namespace Colosseum.Enemy
|
||||
private bool hasJumpTarget = false;
|
||||
private Vector3 jumpStartXZ;
|
||||
private Vector3 jumpTargetXZ;
|
||||
|
||||
// 이벤트
|
||||
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
|
||||
public event Action<float, float> OnShieldChanged; // oldShield, newShield
|
||||
public event Action<float> OnDamageTaken; // damage
|
||||
public event Action OnDeath;
|
||||
|
||||
@@ -67,6 +76,7 @@ namespace Colosseum.Enemy
|
||||
public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f;
|
||||
public float CurrentMana => currentMana.Value;
|
||||
public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f;
|
||||
public float Shield => currentShield.Value;
|
||||
public bool IsDead => isDead.Value;
|
||||
public CharacterStats Stats => characterStats;
|
||||
public EnemyData Data => enemyData;
|
||||
@@ -82,6 +92,8 @@ namespace Colosseum.Enemy
|
||||
animator = GetComponentInChildren<Animator>();
|
||||
if (navMeshAgent == null)
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
if (abnormalityManager == null)
|
||||
abnormalityManager = GetComponent<AbnormalityManager>();
|
||||
|
||||
// 서버에서 초기화
|
||||
if (IsServer)
|
||||
@@ -91,12 +103,18 @@ namespace Colosseum.Enemy
|
||||
|
||||
// 클라이언트에서 체력 변화 감지
|
||||
currentHealth.OnValueChanged += OnHealthChangedInternal;
|
||||
currentShield.OnValueChanged += OnShieldChangedInternal;
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
if (!IsServer || IsDead) return;
|
||||
|
||||
if (shieldCollection.Tick(Time.deltaTime))
|
||||
{
|
||||
RefreshShieldState();
|
||||
}
|
||||
|
||||
UpdateThreatState(Time.deltaTime);
|
||||
OnServerUpdate();
|
||||
}
|
||||
@@ -233,6 +251,7 @@ namespace Colosseum.Enemy
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
currentHealth.OnValueChanged -= OnHealthChangedInternal;
|
||||
currentShield.OnValueChanged -= OnShieldChangedInternal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -255,6 +274,8 @@ namespace Colosseum.Enemy
|
||||
currentHealth.Value = MaxHealth;
|
||||
currentMana.Value = MaxMana;
|
||||
isDead.Value = false;
|
||||
shieldCollection.Clear();
|
||||
RefreshShieldState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -265,9 +286,11 @@ namespace Colosseum.Enemy
|
||||
if (!IsServer || isDead.Value)
|
||||
return 0f;
|
||||
|
||||
float actualDamage = Mathf.Min(damage, currentHealth.Value);
|
||||
float mitigatedDamage = ConsumeShield(damage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
|
||||
RegisterThreatFromDamage(actualDamage, source);
|
||||
OnDamageTaken?.Invoke(actualDamage);
|
||||
|
||||
@@ -308,6 +331,20 @@ namespace Colosseum.Enemy
|
||||
return actualHeal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보호막을 적용합니다.
|
||||
/// </summary>
|
||||
public virtual float ApplyShield(float amount, float duration, AbnormalityData shieldAbnormality = null, GameObject source = null)
|
||||
{
|
||||
if (!IsServer || isDead.Value || amount <= 0f)
|
||||
return 0f;
|
||||
|
||||
AbnormalityData shieldType = shieldAbnormality != null ? shieldAbnormality : shieldStateAbnormality;
|
||||
float actualAppliedShield = shieldCollection.ApplyShield(shieldType, amount, duration, source);
|
||||
RefreshShieldState();
|
||||
return actualAppliedShield;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
|
||||
/// </summary>
|
||||
@@ -342,6 +379,8 @@ namespace Colosseum.Enemy
|
||||
protected virtual void HandleDeath()
|
||||
{
|
||||
isDead.Value = true;
|
||||
shieldCollection.Clear();
|
||||
RefreshShieldState();
|
||||
ClearAllThreat();
|
||||
|
||||
// 실행 중인 스킬 즉시 취소
|
||||
@@ -398,6 +437,31 @@ namespace Colosseum.Enemy
|
||||
OnHealthChanged?.Invoke(newValue, MaxHealth);
|
||||
}
|
||||
|
||||
private void OnShieldChangedInternal(float oldValue, float newValue)
|
||||
{
|
||||
OnShieldChanged?.Invoke(oldValue, newValue);
|
||||
}
|
||||
|
||||
private float ConsumeShield(float incomingDamage)
|
||||
{
|
||||
if (incomingDamage <= 0f || currentShield.Value <= 0f)
|
||||
return incomingDamage;
|
||||
|
||||
float remainingDamage = shieldCollection.ConsumeDamage(incomingDamage);
|
||||
RefreshShieldState();
|
||||
return remainingDamage;
|
||||
}
|
||||
|
||||
private void RefreshShieldState()
|
||||
{
|
||||
currentShield.Value = shieldCollection.TotalAmount;
|
||||
|
||||
ShieldAbnormalityUtility.SyncShieldAbnormalities(
|
||||
abnormalityManager,
|
||||
shieldCollection.ActiveShields,
|
||||
gameObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 공격자 기준 위협 수치를 누적합니다.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,11 +21,16 @@ namespace Colosseum.Player
|
||||
[Tooltip("이상상태 관리자 (없으면 자동 검색)")]
|
||||
[SerializeField] private AbnormalityManager abnormalityManager;
|
||||
|
||||
[Header("Shield")]
|
||||
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
|
||||
[SerializeField] private AbnormalityData shieldStateAbnormality;
|
||||
|
||||
// 네트워크 동기화 변수
|
||||
private NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
|
||||
private NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
|
||||
private NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
|
||||
private NetworkVariable<float> currentShield = new NetworkVariable<float>(0f);
|
||||
private readonly ShieldCollection shieldCollection = new ShieldCollection();
|
||||
|
||||
public float Health => currentHealth.Value;
|
||||
public float Mana => currentMana.Value;
|
||||
@@ -72,8 +77,19 @@ namespace Colosseum.Player
|
||||
{
|
||||
currentHealth.Value = MaxHealth;
|
||||
currentMana.Value = MaxMana;
|
||||
currentShield.Value = 0f;
|
||||
isDead.Value = false;
|
||||
RefreshShieldState();
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer || isDead.Value)
|
||||
return;
|
||||
|
||||
if (shieldCollection.Tick(Time.deltaTime))
|
||||
{
|
||||
RefreshShieldState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,17 +128,7 @@ namespace Colosseum.Player
|
||||
[Rpc(SendTo.Server)]
|
||||
public void TakeDamageRpc(float damage)
|
||||
{
|
||||
if (isDead.Value || IsDamageImmune()) return;
|
||||
|
||||
float finalDamage = damage * GetIncomingDamageMultiplier();
|
||||
float mitigatedDamage = ConsumeShield(finalDamage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
ApplyDamageInternal(damage, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -179,7 +185,8 @@ namespace Colosseum.Player
|
||||
if (isDead.Value) return;
|
||||
|
||||
isDead.Value = true;
|
||||
currentShield.Value = 0f;
|
||||
shieldCollection.Clear();
|
||||
RefreshShieldState();
|
||||
|
||||
// 사망 시 활성 이상 상태를 정리해 리스폰 시 잔존하지 않게 합니다.
|
||||
if (abnormalityManager != null)
|
||||
@@ -245,7 +252,8 @@ namespace Colosseum.Player
|
||||
isDead.Value = false;
|
||||
currentHealth.Value = MaxHealth;
|
||||
currentMana.Value = MaxMana;
|
||||
currentShield.Value = 0f;
|
||||
shieldCollection.Clear();
|
||||
RefreshShieldState();
|
||||
|
||||
// 이동 재활성화
|
||||
var movement = GetComponent<PlayerMovement>();
|
||||
@@ -298,19 +306,7 @@ namespace Colosseum.Player
|
||||
/// </summary>
|
||||
public float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
if (!IsServer || isDead.Value || IsDamageImmune()) return 0f;
|
||||
|
||||
float finalDamage = damage * GetIncomingDamageMultiplier();
|
||||
float mitigatedDamage = ConsumeShield(finalDamage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
|
||||
return actualDamage;
|
||||
return ApplyDamageInternal(damage, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -329,18 +325,15 @@ namespace Colosseum.Player
|
||||
/// <summary>
|
||||
/// 보호막을 적용합니다.
|
||||
/// </summary>
|
||||
public void ApplyShield(float amount, float duration)
|
||||
public float ApplyShield(float amount, float duration, AbnormalityData shieldAbnormality = null, GameObject source = null)
|
||||
{
|
||||
if (!IsServer || isDead.Value || amount <= 0f)
|
||||
return;
|
||||
return 0f;
|
||||
|
||||
currentShield.Value = Mathf.Max(currentShield.Value, amount);
|
||||
|
||||
if (duration > 0f)
|
||||
{
|
||||
CancelInvoke(nameof(ClearShield));
|
||||
Invoke(nameof(ClearShield), duration);
|
||||
}
|
||||
AbnormalityData shieldType = shieldAbnormality != null ? shieldAbnormality : shieldStateAbnormality;
|
||||
float actualAppliedShield = shieldCollection.ApplyShield(shieldType, amount, duration, source);
|
||||
RefreshShieldState();
|
||||
return actualAppliedShield;
|
||||
}
|
||||
|
||||
private bool IsDamageImmune()
|
||||
@@ -361,17 +354,39 @@ namespace Colosseum.Player
|
||||
if (incomingDamage <= 0f || currentShield.Value <= 0f)
|
||||
return incomingDamage;
|
||||
|
||||
float shieldAbsorb = Mathf.Min(currentShield.Value, incomingDamage);
|
||||
currentShield.Value = Mathf.Max(0f, currentShield.Value - shieldAbsorb);
|
||||
return Mathf.Max(0f, incomingDamage - shieldAbsorb);
|
||||
float remainingDamage = shieldCollection.ConsumeDamage(incomingDamage);
|
||||
RefreshShieldState();
|
||||
return remainingDamage;
|
||||
}
|
||||
|
||||
private void ClearShield()
|
||||
private float ApplyDamageInternal(float damage, object source)
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
if (!IsServer || isDead.Value || IsDamageImmune())
|
||||
return 0f;
|
||||
|
||||
currentShield.Value = 0f;
|
||||
float finalDamage = damage * GetIncomingDamageMultiplier();
|
||||
float mitigatedDamage = ConsumeShield(finalDamage);
|
||||
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
|
||||
return actualDamage;
|
||||
}
|
||||
|
||||
private void RefreshShieldState()
|
||||
{
|
||||
currentShield.Value = shieldCollection.TotalAmount;
|
||||
|
||||
ShieldAbnormalityUtility.SyncShieldAbnormalities(
|
||||
abnormalityManager,
|
||||
shieldCollection.ActiveShields,
|
||||
gameObject);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ namespace Colosseum.Skills.Effects
|
||||
var damageable = target.GetComponent<IDamageable>();
|
||||
if (damageable != null)
|
||||
{
|
||||
damageable.Heal(totalHeal);
|
||||
float actualHeal = damageable.Heal(totalHeal);
|
||||
CombatBalanceTracker.RecordHeal(caster, target, actualHeal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Abnormalities;
|
||||
using Colosseum.Enemy;
|
||||
using Colosseum.Player;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Combat;
|
||||
|
||||
namespace Colosseum.Skills.Effects
|
||||
{
|
||||
@@ -22,17 +25,30 @@ namespace Colosseum.Skills.Effects
|
||||
[Tooltip("보호막 지속 시간")]
|
||||
[Min(0f)] [SerializeField] private float duration = 5f;
|
||||
|
||||
[Header("Abnormality")]
|
||||
[Tooltip("보호막 활성 여부를 나타내는 이상상태 데이터")]
|
||||
[SerializeField] private AbnormalityData shieldStateAbnormality;
|
||||
|
||||
protected override void ApplyEffect(GameObject caster, GameObject target)
|
||||
{
|
||||
if (target == null)
|
||||
return;
|
||||
|
||||
PlayerNetworkController networkController = target.GetComponent<PlayerNetworkController>();
|
||||
if (networkController == null)
|
||||
return;
|
||||
|
||||
float totalShield = CalculateShield(caster);
|
||||
networkController.ApplyShield(totalShield, duration);
|
||||
PlayerNetworkController playerNetworkController = target.GetComponent<PlayerNetworkController>();
|
||||
if (playerNetworkController != null)
|
||||
{
|
||||
float actualShield = playerNetworkController.ApplyShield(totalShield, duration, shieldStateAbnormality, caster);
|
||||
CombatBalanceTracker.RecordShield(caster, target, actualShield);
|
||||
return;
|
||||
}
|
||||
|
||||
EnemyBase enemyBase = target.GetComponent<EnemyBase>();
|
||||
if (enemyBase != null)
|
||||
{
|
||||
float actualShield = enemyBase.ApplyShield(totalShield, duration, shieldStateAbnormality, caster);
|
||||
CombatBalanceTracker.RecordShield(caster, target, actualShield);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateShield(GameObject caster)
|
||||
|
||||
@@ -82,6 +82,7 @@ namespace Colosseum.Skills.Effects
|
||||
|
||||
float desiredThreat = Mathf.Max(currentCasterThreat + flatThreatAmount, highestThreat + threatLeadBonus + flatThreatAmount);
|
||||
enemy.SetThreat(caster, desiredThreat);
|
||||
CombatBalanceTracker.RecordThreat(caster, Mathf.Max(0f, desiredThreat - currentCasterThreat));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
using Colosseum.Abnormalities;
|
||||
using Colosseum.Enemy;
|
||||
|
||||
namespace Colosseum.UI
|
||||
@@ -23,6 +26,9 @@ namespace Colosseum.UI
|
||||
[Tooltip("보스 이름 텍스트")]
|
||||
[SerializeField] private TMP_Text bossNameText;
|
||||
|
||||
[Tooltip("보스 이상상태 요약 텍스트 (비어 있으면 런타임에 자동 생성)")]
|
||||
[SerializeField] private TMP_Text abnormalitySummaryText;
|
||||
|
||||
[Header("Target")]
|
||||
[Tooltip("추적할 보스 (런타임에 설정 가능)")]
|
||||
[SerializeField] private BossEnemy targetBoss;
|
||||
@@ -34,15 +40,30 @@ namespace Colosseum.UI
|
||||
[Tooltip("슬라이더 값 변환 속도")]
|
||||
[Min(0f)] [SerializeField] private float lerpSpeed = 5f;
|
||||
|
||||
[Tooltip("보스 이상상태 요약 텍스트를 자동 생성할지 여부")]
|
||||
[SerializeField] private bool autoCreateAbnormalitySummary = true;
|
||||
|
||||
[Header("Signature UI")]
|
||||
[Tooltip("시그니처 패턴 전용 UI를 표시할지 여부")]
|
||||
[SerializeField] private bool showSignatureUi = false;
|
||||
[Tooltip("집행 개시 진행도를 표시할 루트 오브젝트")]
|
||||
[SerializeField] private RectTransform signatureRoot;
|
||||
[SerializeField] private Image signatureFillImage;
|
||||
[SerializeField] private TMP_Text signatureNameText;
|
||||
[SerializeField] private TMP_Text signatureDetailText;
|
||||
|
||||
private float displayHealthRatio;
|
||||
private float targetHealthRatio;
|
||||
private bool isSubscribed;
|
||||
private bool isSubscribedToStaticEvent;
|
||||
private BossCombatBehaviorContext bossCombatContext;
|
||||
private AbnormalityManager targetAbnormalityManager;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 추적 중인 보스
|
||||
/// </summary>
|
||||
public BossEnemy TargetBoss => targetBoss;
|
||||
public string CurrentAbnormalitySummary => abnormalitySummaryText != null ? abnormalitySummaryText.text : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 보스 수동 설정 (런타임에서 호출)
|
||||
@@ -53,6 +74,7 @@ namespace Colosseum.UI
|
||||
UnsubscribeFromBoss();
|
||||
|
||||
targetBoss = boss;
|
||||
targetAbnormalityManager = targetBoss != null ? targetBoss.GetComponent<AbnormalityManager>() : null;
|
||||
|
||||
// 새 보스 이벤트 구독
|
||||
SubscribeToBoss();
|
||||
@@ -60,12 +82,20 @@ namespace Colosseum.UI
|
||||
// 초기 UI 업데이트
|
||||
if (targetBoss != null)
|
||||
{
|
||||
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
|
||||
EnsureAbnormalitySummaryText();
|
||||
UpdateBossName();
|
||||
UpdateHealthImmediate();
|
||||
UpdateAbnormalitySummary();
|
||||
UpdateSignatureUi();
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
bossCombatContext = null;
|
||||
if (abnormalitySummaryText != null)
|
||||
abnormalitySummaryText.text = string.Empty;
|
||||
SetSignatureVisible(false);
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +123,13 @@ namespace Colosseum.UI
|
||||
|
||||
if (bossNameText == null)
|
||||
bossNameText = transform.Find("SliderBox/Label_BossName")?.GetComponent<TMP_Text>();
|
||||
|
||||
if (showSignatureUi)
|
||||
{
|
||||
EnsureSignatureUi();
|
||||
}
|
||||
|
||||
EnsureAbnormalitySummaryText();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
@@ -124,8 +161,11 @@ namespace Colosseum.UI
|
||||
else if (targetBoss != null)
|
||||
{
|
||||
SubscribeToBoss();
|
||||
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
|
||||
UpdateBossName();
|
||||
UpdateHealthImmediate();
|
||||
UpdateAbnormalitySummary();
|
||||
UpdateSignatureUi();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -145,6 +185,14 @@ namespace Colosseum.UI
|
||||
|
||||
UpdateSliderVisual();
|
||||
}
|
||||
|
||||
UpdateSignatureUi();
|
||||
|
||||
if (targetBoss != null)
|
||||
{
|
||||
UpdateHealthText(targetBoss.CurrentHealth, targetBoss.MaxHealth);
|
||||
UpdateAbnormalitySummary();
|
||||
}
|
||||
}
|
||||
private void OnDestroy()
|
||||
{
|
||||
@@ -218,7 +266,10 @@ namespace Colosseum.UI
|
||||
{
|
||||
if (healthText != null)
|
||||
{
|
||||
healthText.text = $"{Mathf.CeilToInt(currentHealth)} / {Mathf.CeilToInt(maxHealth)}";
|
||||
int shieldValue = targetBoss != null ? Mathf.CeilToInt(targetBoss.Shield) : 0;
|
||||
healthText.text = shieldValue > 0
|
||||
? $"{Mathf.CeilToInt(currentHealth)} / {Mathf.CeilToInt(maxHealth)} (+{shieldValue})"
|
||||
: $"{Mathf.CeilToInt(currentHealth)} / {Mathf.CeilToInt(maxHealth)}";
|
||||
}
|
||||
}
|
||||
private void UpdateBossName()
|
||||
@@ -237,6 +288,221 @@ namespace Colosseum.UI
|
||||
bossNameText.text = targetBoss.name;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureAbnormalitySummaryText()
|
||||
{
|
||||
if (abnormalitySummaryText != null || !autoCreateAbnormalitySummary)
|
||||
return;
|
||||
|
||||
Transform sliderBox = transform.Find("SliderBox");
|
||||
if (sliderBox == null)
|
||||
sliderBox = transform;
|
||||
|
||||
GameObject summaryObject = new GameObject("Label_BossAbnormalities", typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||
summaryObject.transform.SetParent(sliderBox, false);
|
||||
|
||||
RectTransform rectTransform = summaryObject.GetComponent<RectTransform>();
|
||||
rectTransform.anchorMin = new Vector2(0f, 1f);
|
||||
rectTransform.anchorMax = new Vector2(1f, 1f);
|
||||
rectTransform.pivot = new Vector2(0.5f, 1f);
|
||||
rectTransform.anchoredPosition = new Vector2(0f, -32f);
|
||||
rectTransform.sizeDelta = new Vector2(0f, 24f);
|
||||
|
||||
TextMeshProUGUI summaryText = summaryObject.GetComponent<TextMeshProUGUI>();
|
||||
summaryText.fontSize = 16f;
|
||||
summaryText.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
summaryText.textWrappingMode = TextWrappingModes.NoWrap;
|
||||
summaryText.richText = true;
|
||||
summaryText.text = string.Empty;
|
||||
|
||||
if (bossNameText != null && bossNameText.font != null)
|
||||
{
|
||||
summaryText.font = bossNameText.font;
|
||||
summaryText.fontSharedMaterial = bossNameText.fontSharedMaterial;
|
||||
}
|
||||
else if (TMP_Settings.defaultFontAsset != null)
|
||||
{
|
||||
summaryText.font = TMP_Settings.defaultFontAsset;
|
||||
}
|
||||
|
||||
abnormalitySummaryText = summaryText;
|
||||
}
|
||||
|
||||
private void UpdateAbnormalitySummary()
|
||||
{
|
||||
if (abnormalitySummaryText == null)
|
||||
{
|
||||
EnsureAbnormalitySummaryText();
|
||||
}
|
||||
|
||||
if (abnormalitySummaryText == null)
|
||||
return;
|
||||
|
||||
if (targetAbnormalityManager == null)
|
||||
{
|
||||
abnormalitySummaryText.text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<ActiveAbnormality> activeAbnormalities = targetAbnormalityManager.ActiveAbnormalities;
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < activeAbnormalities.Count; i++)
|
||||
{
|
||||
ActiveAbnormality abnormality = activeAbnormalities[i];
|
||||
if (abnormality?.Data == null || !abnormality.Data.showInUI)
|
||||
continue;
|
||||
|
||||
if (builder.Length > 0)
|
||||
builder.Append(" ");
|
||||
|
||||
string color = abnormality.Data.isDebuff ? "#FF7070" : "#70D0FF";
|
||||
builder.Append("<color=");
|
||||
builder.Append(color);
|
||||
builder.Append(">");
|
||||
builder.Append(abnormality.Data.abnormalityName);
|
||||
|
||||
if (!abnormality.Data.IsPermanent)
|
||||
{
|
||||
builder.Append(" ");
|
||||
builder.Append(Mathf.CeilToInt(Mathf.Max(0f, abnormality.RemainingDuration)));
|
||||
builder.Append("s");
|
||||
}
|
||||
|
||||
builder.Append("</color>");
|
||||
}
|
||||
|
||||
abnormalitySummaryText.text = builder.ToString();
|
||||
}
|
||||
|
||||
private void UpdateSignatureUi()
|
||||
{
|
||||
if (!showSignatureUi)
|
||||
{
|
||||
SetSignatureVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signatureRoot == null)
|
||||
return;
|
||||
|
||||
if (targetBoss == null)
|
||||
{
|
||||
SetSignatureVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bossCombatContext == null)
|
||||
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
|
||||
|
||||
if (bossCombatContext == null || !bossCombatContext.IsSignaturePatternActive)
|
||||
{
|
||||
SetSignatureVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
SetSignatureVisible(true);
|
||||
|
||||
if (signatureNameText != null)
|
||||
{
|
||||
signatureNameText.text = string.IsNullOrEmpty(bossCombatContext.SignaturePatternName)
|
||||
? "시그니처"
|
||||
: bossCombatContext.SignaturePatternName;
|
||||
}
|
||||
|
||||
if (signatureDetailText != null)
|
||||
{
|
||||
signatureDetailText.text =
|
||||
$"차단 {Mathf.CeilToInt(bossCombatContext.SignatureAccumulatedDamage)} / {Mathf.CeilToInt(bossCombatContext.SignatureRequiredDamage)}" +
|
||||
$" | {bossCombatContext.SignatureRemainingTime:0.0}s";
|
||||
}
|
||||
|
||||
if (signatureFillImage != null)
|
||||
{
|
||||
signatureFillImage.fillAmount = 1f - bossCombatContext.SignatureCastProgressNormalized;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureSignatureUi()
|
||||
{
|
||||
if (!showSignatureUi)
|
||||
return;
|
||||
|
||||
if (signatureRoot != null && signatureFillImage != null && signatureNameText != null && signatureDetailText != null)
|
||||
return;
|
||||
|
||||
Transform sliderBox = transform.Find("SliderBox");
|
||||
if (sliderBox == null)
|
||||
sliderBox = transform;
|
||||
|
||||
GameObject rootObject = new GameObject("SignatureBar", typeof(RectTransform), typeof(Image));
|
||||
rootObject.transform.SetParent(sliderBox, false);
|
||||
|
||||
signatureRoot = rootObject.GetComponent<RectTransform>();
|
||||
Image backgroundImage = rootObject.GetComponent<Image>();
|
||||
backgroundImage.color = new Color(0.08f, 0.08f, 0.08f, 0.88f);
|
||||
|
||||
signatureRoot.anchorMin = new Vector2(0f, 0f);
|
||||
signatureRoot.anchorMax = new Vector2(1f, 0f);
|
||||
signatureRoot.pivot = new Vector2(0.5f, 1f);
|
||||
signatureRoot.anchoredPosition = new Vector2(0f, -48f);
|
||||
signatureRoot.sizeDelta = new Vector2(0f, 42f);
|
||||
|
||||
GameObject fillObject = new GameObject("Fill", typeof(RectTransform), typeof(Image));
|
||||
fillObject.transform.SetParent(signatureRoot, false);
|
||||
|
||||
RectTransform fillRect = fillObject.GetComponent<RectTransform>();
|
||||
signatureFillImage = fillObject.GetComponent<Image>();
|
||||
signatureFillImage.color = new Color(0.88f, 0.48f, 0.12f, 0.95f);
|
||||
signatureFillImage.type = Image.Type.Filled;
|
||||
signatureFillImage.fillMethod = Image.FillMethod.Horizontal;
|
||||
signatureFillImage.fillOrigin = 0;
|
||||
signatureFillImage.fillAmount = 1f;
|
||||
|
||||
fillRect.anchorMin = new Vector2(0f, 0f);
|
||||
fillRect.anchorMax = new Vector2(1f, 1f);
|
||||
fillRect.offsetMin = new Vector2(2f, 2f);
|
||||
fillRect.offsetMax = new Vector2(-2f, -2f);
|
||||
|
||||
signatureNameText = CreateSignatureText("Label_SignatureName", TextAlignmentOptions.TopLeft, 18f, FontStyles.Bold);
|
||||
signatureDetailText = CreateSignatureText("Label_SignatureDetail", TextAlignmentOptions.TopRight, 15f, FontStyles.Normal);
|
||||
|
||||
SetSignatureVisible(false);
|
||||
}
|
||||
|
||||
private TMP_Text CreateSignatureText(string objectName, TextAlignmentOptions alignment, float fontSize, FontStyles fontStyle)
|
||||
{
|
||||
GameObject textObject = new GameObject(objectName, typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||
textObject.transform.SetParent(signatureRoot, false);
|
||||
|
||||
RectTransform rectTransform = textObject.GetComponent<RectTransform>();
|
||||
rectTransform.anchorMin = new Vector2(0f, 0f);
|
||||
rectTransform.anchorMax = new Vector2(1f, 1f);
|
||||
rectTransform.offsetMin = new Vector2(6f, 4f);
|
||||
rectTransform.offsetMax = new Vector2(-6f, -4f);
|
||||
|
||||
TMP_Text text = textObject.GetComponent<TextMeshProUGUI>();
|
||||
text.alignment = alignment;
|
||||
text.fontSize = fontSize;
|
||||
text.fontStyle = fontStyle;
|
||||
text.color = Color.white;
|
||||
text.textWrappingMode = TextWrappingModes.NoWrap;
|
||||
if (bossNameText != null && bossNameText.font != null)
|
||||
{
|
||||
text.font = bossNameText.font;
|
||||
text.fontSharedMaterial = bossNameText.fontSharedMaterial;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private void SetSignatureVisible(bool visible)
|
||||
{
|
||||
if (signatureRoot == null || signatureRoot.gameObject.activeSelf == visible)
|
||||
return;
|
||||
|
||||
signatureRoot.gameObject.SetActive(visible);
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user