- 드로그 전투 컨텍스트를 BossBehaviorRuntimeState 중심 구조로 정리하고 BossEnemy, 패턴 액션, 조건 노드가 마지막 실행 결과와 phase 상태를 직접 사용하도록 갱신 - BT_Drog와 재빌드 에디터 스크립트를 확장해 phase 전환, 집행 결과 분기, 거리/쿨타임 기반 패턴 선택을 드로그 전용 자산과 노드 파라미터로 재구성 - 드로그 패턴/스킬/이펙트/애니메이션 플레이스홀더 자산을 재생성하고 보스 프리팹이 새 런타임 상태 및 등록 클립 구성을 참조하도록 정리
477 lines
17 KiB
C#
477 lines
17 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 BossBehaviorRuntimeState bossRuntimeState;
|
|
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)
|
|
{
|
|
bossRuntimeState = targetBoss.GetComponent<BossBehaviorRuntimeState>();
|
|
EnsureAbnormalitySummaryText();
|
|
UpdateBossName();
|
|
UpdateHealthImmediate();
|
|
UpdateAbnormalitySummary();
|
|
UpdateSignatureUi();
|
|
gameObject.SetActive(true);
|
|
}
|
|
else
|
|
{
|
|
bossRuntimeState = 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();
|
|
bossRuntimeState = targetBoss.GetComponent<BossBehaviorRuntimeState>();
|
|
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()
|
|
{
|
|
// 시그니처 패턴은 이제 BT의 ChargeWait 스텝으로 처리됩니다.
|
|
// 시그니처 전용 UI 업데이트는 비활성화합니다.
|
|
if (signatureRoot == null)
|
|
return;
|
|
|
|
SetSignatureVisible(false);
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|