feat: 보호막 타입 분리 및 드로그 시그니처 전조 정리
- 보호막을 단일 수치에서 타입별 독립 인스턴스 구조로 리팩터링하고 같은 타입만 갱신되도록 정리 - 플레이어/보스 보호막 상태를 이상상태와 연동해 HUD 및 보스 UI에서 타입별로 식별 가능하게 보강 - 드로그 집행 개시 전조를 집행 준비 이상상태 기반으로 재구성하고 관련 데이터와 보스 컨텍스트를 정리 - 전투 밸런스 계측기와 디버그 메뉴를 추가해 피해, 치유, 보호막, 위협, 패턴 사용량 측정 경로를 마련 - 테스트용 보호막 A/B와 시그니처 전조 자산을 추가하고 기본 포트 7777 원복 후 빌드 및 런타임 검증을 완료
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user