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

515 lines
18 KiB
C#

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
{
/// <summary>
/// 보스 체력바 UI 컴포넌트.
/// BossEnemy의 체력 변화를 자동으로 UI에 반영합니다.
/// </summary>
public class BossHealthBarUI : MonoBehaviour
{
[Header("References")]
[Tooltip("체력 슬라이더 (없으면 자동 검색)")]
[SerializeField] private Slider healthSlider;
[Tooltip("체력 텍스트 (예: '999 / 999')")]
[SerializeField] private TMP_Text healthText;
[Tooltip("보스 이름 텍스트")]
[SerializeField] private TMP_Text bossNameText;
[Tooltip("보스 이상상태 요약 텍스트 (비어 있으면 런타임에 자동 생성)")]
[SerializeField] private TMP_Text abnormalitySummaryText;
[Header("Target")]
[Tooltip("추적할 보스 (런타임에 설정 가능)")]
[SerializeField] private BossEnemy targetBoss;
[Header("Settings")]
[Tooltip("보스 사망 시 UI 숨김 여부")]
[SerializeField] private bool hideOnDeath = true;
[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>
/// 보스 수동 설정 (런타임에서 호출)
/// </summary>
public void SetBoss(BossEnemy boss)
{
// 기존 보스 이벤트 구독 해제
UnsubscribeFromBoss();
targetBoss = boss;
targetAbnormalityManager = targetBoss != null ? targetBoss.GetComponent<AbnormalityManager>() : null;
// 새 보스 이벤트 구독
SubscribeToBoss();
// 초기 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);
}
}
/// <summary>
/// 보스 스폰 이벤트 핸들러
/// </summary>
private void OnBossSpawned(BossEnemy boss)
{
if (boss == null)
return;
SetBoss(boss);
}
private void Awake()
{
// 컴포넌트 자동 검색
if (healthSlider == null)
healthSlider = GetComponentInChildren<Slider>();
if (healthText == null)
healthText = transform.Find("SliderBox/Label_HP")?.GetComponent<TMP_Text>();
if (bossNameText == null)
bossNameText = transform.Find("SliderBox/Label_BossName")?.GetComponent<TMP_Text>();
if (showSignatureUi)
{
EnsureSignatureUi();
}
EnsureAbnormalitySummaryText();
}
private void OnEnable()
{
// 정적 이벤트 구독 (보스 스폰 자동 감지)
if (!isSubscribedToStaticEvent)
{
BossEnemy.OnBossSpawned += OnBossSpawned;
isSubscribedToStaticEvent = true;
}
}
private void OnDisable()
{
// 정적 이벤트 구독 해제
if (isSubscribedToStaticEvent)
{
BossEnemy.OnBossSpawned -= OnBossSpawned;
isSubscribedToStaticEvent = false;
}
}
private void Start()
{
// 이미 활성화된 보스가 있으면 연결
if (BossEnemy.ActiveBoss != null)
{
SetBoss(BossEnemy.ActiveBoss);
}
// 인스펙터에서 설정된 보스가 있으면 구독
else if (targetBoss != null)
{
SubscribeToBoss();
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
UpdateBossName();
UpdateHealthImmediate();
UpdateAbnormalitySummary();
UpdateSignatureUi();
}
else
{
// 보스가 없으면 비활성화 (이벤트 대기)
gameObject.SetActive(false);
}
}
private void Update()
{
// 부드러운 체력바 애니메이션
if (!Mathf.Approximately(displayHealthRatio, targetHealthRatio))
{
displayHealthRatio = Mathf.Lerp(displayHealthRatio, targetHealthRatio, lerpSpeed * Time.deltaTime);
if (Mathf.Abs(displayHealthRatio - targetHealthRatio) < 0.01f)
displayHealthRatio = targetHealthRatio;
UpdateSliderVisual();
}
UpdateSignatureUi();
if (targetBoss != null)
{
UpdateHealthText(targetBoss.CurrentHealth, targetBoss.MaxHealth);
UpdateAbnormalitySummary();
}
}
private void OnDestroy()
{
UnsubscribeFromBoss();
// 정적 이벤트 구독 해제
if (isSubscribedToStaticEvent)
{
BossEnemy.OnBossSpawned -= OnBossSpawned;
isSubscribedToStaticEvent = false;
}
}
private void SubscribeToBoss()
{
if (targetBoss == null || isSubscribed)
return;
targetBoss.OnHealthChanged += OnBossHealthChanged;
targetBoss.OnDeath += OnBossDeath;
isSubscribed = true;
}
private void UnsubscribeFromBoss()
{
if (targetBoss == null || !isSubscribed)
return;
targetBoss.OnHealthChanged -= OnBossHealthChanged;
targetBoss.OnDeath -= OnBossDeath;
isSubscribed = false;
}
private void OnBossHealthChanged(float currentHealth, float maxHealth)
{
if (maxHealth <= 0f)
return;
targetHealthRatio = Mathf.Clamp01(currentHealth / maxHealth);
UpdateHealthText(currentHealth, maxHealth);
}
private void OnBossDeath()
{
if (hideOnDeath)
{
gameObject.SetActive(false);
}
}
private void UpdateHealthImmediate()
{
if (targetBoss == null)
return;
float currentHealth = targetBoss.CurrentHealth;
float maxHealth = targetBoss.MaxHealth;
if (maxHealth <= 0f)
return;
targetHealthRatio = Mathf.Clamp01(currentHealth / maxHealth);
displayHealthRatio = targetHealthRatio;
UpdateSliderVisual();
UpdateHealthText(currentHealth, maxHealth);
}
private void UpdateSliderVisual()
{
if (healthSlider != null)
{
healthSlider.value = displayHealthRatio;
}
}
private void UpdateHealthText(float currentHealth, float maxHealth)
{
if (healthText != null)
{
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()
{
if (bossNameText == null || targetBoss == null)
return;
// EnemyData에서 보스 이름 가져오기
if (targetBoss.Data != null && !string.IsNullOrEmpty(targetBoss.Data.EnemyName))
{
bossNameText.text = targetBoss.Data.EnemyName;
}
else
{
// 폴백: GameObject 이름 사용
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()
{
if (healthSlider == null)
healthSlider = GetComponentInChildren<Slider>();
}
#endif
}
}