feat: 보호막 타입 분리 및 드로그 시그니처 전조 정리

- 보호막을 단일 수치에서 타입별 독립 인스턴스 구조로 리팩터링하고 같은 타입만 갱신되도록 정리
- 플레이어/보스 보호막 상태를 이상상태와 연동해 HUD 및 보스 UI에서 타입별로 식별 가능하게 보강
- 드로그 집행 개시 전조를 집행 준비 이상상태 기반으로 재구성하고 관련 데이터와 보스 컨텍스트를 정리
- 전투 밸런스 계측기와 디버그 메뉴를 추가해 피해, 치유, 보호막, 위협, 패턴 사용량 측정 경로를 마련
- 테스트용 보호막 A/B와 시그니처 전조 자산을 추가하고 기본 포트 7777 원복 후 빌드 및 런타임 검증을 완료
This commit is contained in:
2026-03-26 11:19:19 +09:00
parent 3db8acfaaa
commit aaa7d2d6a7
31 changed files with 2327 additions and 693 deletions

View File

@@ -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>

View File

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