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

@@ -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
@@ -22,6 +25,9 @@ namespace Colosseum.UI
[Tooltip("보스 이름 텍스트")]
[SerializeField] private TMP_Text bossNameText;
[Tooltip("보스 이상상태 요약 텍스트 (비어 있으면 런타임에 자동 생성)")]
[SerializeField] private TMP_Text abnormalitySummaryText;
[Header("Target")]
[Tooltip("추적할 보스 (런타임에 설정 가능)")]
@@ -33,16 +39,31 @@ 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()
{