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

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