[UI] 보스 체력바 UI 및 영역 진입 트리거 시스템 추가

- BossHealthBarUI: 보스 체력 변화를 자동으로 UI에 반영하는 컴포넌트
- BossArea: 플레이어 진입 시 연결된 보스의 체력바 표시
- BossEnemy: 스폰 이벤트(OnBossSpawned) 추가로 UI 자동 연결 지원
- UI_BossHealthBar.prefab: BossHealthBarUI 컴포넌트 적용
This commit is contained in:
2026-03-12 02:35:43 +09:00
parent a7b89ff861
commit bc52a08d2b
7 changed files with 1578 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2122b1e1b36684a40978673f272f200e
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,250 @@
using System;
using UnityEngine;
using Colosseum.UI;
using Colosseum.Player;
namespace Colosseum.Enemy
{
/// <summary>
/// 보스 영역 트리거.
/// 플레이어가 이 영역에 진입하면 연결된 보스의 체력바 UI를 표시합니다.
/// </summary>
[RequireComponent(typeof(Collider))]
public class BossArea : MonoBehaviour
{
[Header("Boss Reference")]
[Tooltip("이 영역에 연결된 보스")]
[SerializeField] private BossEnemy boss;
[Header("UI Settings")]
[Tooltip("보스 체력바 UI (없으면 씬에서 자동 검색)")]
[SerializeField] private BossHealthBarUI bossHealthBarUI;
[Header("Trigger Settings")]
[Tooltip("플레이어 퇴장 시 UI 숨김 여부")]
[SerializeField] private bool hideOnExit = false;
[Tooltip("영역 진입 시 한 번만 표시")]
[SerializeField] private bool showOnceOnly = false;
// 이벤트
/// <summary>
/// 플레이어 진입 시 호출
/// </summary>
public event Action OnPlayerEnter;
/// <summary>
/// 플레이어 퇴장 시 호출
/// </summary>
public event Action OnPlayerExit;
// 상태
private bool hasShownUI = false;
private bool isPlayerInArea = false;
private Collider triggerCollider;
[Header("Debug")]
[SerializeField] private bool debugMode = false;
/// <summary>
/// 연결된 보스
/// </summary>
public BossEnemy Boss => boss;
/// <summary>
/// 플레이어가 영역 내에 있는지 여부
/// </summary>
public bool IsPlayerInArea => isPlayerInArea;
private void Awake()
{
// Collider 설정 확인
triggerCollider = GetComponent<Collider>();
if (triggerCollider != null && !triggerCollider.isTrigger)
{
Debug.LogWarning($"[BossArea] {name}: Collider가 Trigger가 아닙니다. 자동으로 Trigger로 설정합니다.");
triggerCollider.isTrigger = true;
}
}
private void Start()
{
// BossHealthBarUI 자동 검색
if (bossHealthBarUI == null)
{
bossHealthBarUI = FindObjectOfType<BossHealthBarUI>();
if (bossHealthBarUI == null)
{
Debug.LogWarning($"[BossArea] {name}: BossHealthBarUI를 찾을 수 없습니다.");
}
}
// 보스 참조 확인
if (boss == null)
{
Debug.LogWarning($"[BossArea] {name}: 연결된 보스가 없습니다.");
}
}
private void OnTriggerEnter(Collider other)
{
// 이미 표시했고 한 번만 표시 설정이면 무시
if (showOnceOnly && hasShownUI)
return;
// 플레이어 확인 (태그 또는 컴포넌트)
if (!IsPlayer(other, out var playerController))
return;
isPlayerInArea = true;
ShowBossHealthBar();
OnPlayerEnter?.Invoke();
if (debugMode)
Debug.Log($"[BossArea] {name}: 플레이어 진입 - 보스: {boss?.name ?? ""}");
}
private void OnTriggerExit(Collider other)
{
// 플레이어 확인
if (!IsPlayer(other, out var playerController))
return;
isPlayerInArea = false;
if (hideOnExit)
{
HideBossHealthBar();
}
OnPlayerExit?.Invoke();
if (debugMode)
Debug.Log($"[BossArea] {name}: 플레이어 퇴장");
}
/// <summary>
/// 보스 체력바 표시
/// </summary>
public void ShowBossHealthBar()
{
if (boss == null || bossHealthBarUI == null)
return;
// BossHealthBarUI에 보스 설정
bossHealthBarUI.SetBoss(boss);
hasShownUI = true;
}
/// <summary>
/// 보스 체력바 숨김
/// </summary>
public void HideBossHealthBar()
{
if (bossHealthBarUI == null)
return;
bossHealthBarUI.gameObject.SetActive(false);
}
/// <summary>
/// 플레이어 여부 확인
/// </summary>
private bool IsPlayer(Collider other, out PlayerNetworkController playerController)
{
playerController = null;
// 1. 태그로 확인
if (other.CompareTag("Player"))
{
playerController = other.GetComponent<PlayerNetworkController>();
return true;
}
// 2. 컴포넌트로 확인
playerController = other.GetComponent<PlayerNetworkController>();
if (playerController != null)
return true;
// 3. 부모에서 검색
playerController = other.GetComponentInParent<PlayerNetworkController>();
return playerController != null;
}
/// <summary>
/// 보스 수동 설정
/// </summary>
public void SetBoss(BossEnemy newBoss)
{
boss = newBoss;
}
/// <summary>
/// UI 수동 설정
/// </summary>
public void SetHealthBarUI(BossHealthBarUI ui)
{
bossHealthBarUI = ui;
}
/// <summary>
/// 상태 초기화 (재진입 허용)
/// </summary>
public void ResetState()
{
hasShownUI = false;
isPlayerInArea = false;
}
#region Debug Gizmos
private void OnDrawGizmos()
{
if (!debugMode)
return;
// 영역 시각화
Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f); // 주황색 반투명
var col = GetComponent<Collider>();
if (col is BoxCollider boxCol)
{
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(boxCol.center, boxCol.size);
}
else if (col is SphereCollider sphereCol)
{
Gizmos.DrawSphere(transform.position + sphereCol.center, sphereCol.radius);
}
else if (col is CapsuleCollider capsuleCol)
{
// 캡슐은 구+실린더로 근접 표현
Gizmos.DrawWireSphere(transform.position + capsuleCol.center, capsuleCol.radius);
}
// 보스 연결 표시
if (boss != null)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position, boss.transform.position);
}
}
private void OnDrawGizmosSelected()
{
// 선택 시 더 명확하게 표시
Gizmos.color = new Color(1f, 0.3f, 0f, 0.5f);
var col = GetComponent<Collider>();
if (col is BoxCollider boxCol)
{
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(boxCol.center, boxCol.size);
}
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1735054b0ca6d674b99668aeb74ba273

View File

@@ -47,6 +47,16 @@ namespace Colosseum.Enemy
public event System.Action<int> OnPhaseChanged; // phaseIndex
public event System.Action<float> OnPhaseTransitionStart; // transitionDuration
public event System.Action OnPhaseTransitionEnd;
// 정적 이벤트 (UI 자동 연결용)
/// <summary>
/// 보스 스폰 시 발생하는 정적 이벤트
/// </summary>
public static event System.Action<BossEnemy> OnBossSpawned;
/// <summary>
/// 현재 활성화된 보스 (Scene에 하나만 존재한다고 가정)
/// </summary>
public static BossEnemy ActiveBoss { get; private set; }
// Properties
public int CurrentPhaseIndex => currentPhaseIndex;
@@ -71,8 +81,18 @@ namespace Colosseum.Enemy
{
behaviorAgent.Graph = initialBehaviorGraph;
}
// 정적 이벤트 발생 (UI 자동 연결용)
ActiveBoss = this;
OnBossSpawned?.Invoke(this);
if (debugMode)
{
Debug.Log($"[Boss] Boss spawned: {name}");
}
}
protected override void InitializeStats()
{
base.InitializeStats();

View File

@@ -0,0 +1,248 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
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;
[Header("Target")]
[Tooltip("추적할 보스 (런타임에 설정 가능)")]
[SerializeField] private BossEnemy targetBoss;
[Header("Settings")]
[Tooltip("보스 사망 시 UI 숨김 여부")]
[SerializeField] private bool hideOnDeath = true;
[Tooltip("슬라이더 값 변환 속도")]
[Min(0f)] [SerializeField] private float lerpSpeed = 5f;
private float displayHealthRatio;
private float targetHealthRatio;
private bool isSubscribed;
private bool isSubscribedToStaticEvent;
/// <summary>
/// 현재 추적 중인 보스
/// </summary>
public BossEnemy TargetBoss => targetBoss;
/// <summary>
/// 보스 수동 설정 (런타임에서 호출)
/// </summary>
public void SetBoss(BossEnemy boss)
{
// 기존 보스 이벤트 구독 해제
UnsubscribeFromBoss();
targetBoss = boss;
// 새 보스 이벤트 구독
SubscribeToBoss();
// 초기 UI 업데이트
if (targetBoss != null)
{
UpdateBossName();
UpdateHealthImmediate();
gameObject.SetActive(true);
}
else
{
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>();
}
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();
UpdateBossName();
UpdateHealthImmediate();
}
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();
}
}
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)
{
healthText.text = $"{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;
}
}
#if UNITY_EDITOR
private void OnValidate()
{
if (healthSlider == null)
healthSlider = GetComponentInChildren<Slider>();
}
#endif
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 892f9842e85256b47b24e0aab016820b