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

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