// ─────────────────────────────────────────────────────────────────────────────
// BossHpBar.cs — 상단 보스 HP 바 + 페이즈 눈금 (WL-813c · #813)
//
// 기준서 v1 §B 요소 6 "등장 · 처치 연출": 등장 배너 + 상단 HP 바 + 페이즈 눈금(임계 100/70/40).
// §C 요소 6 현행: "상단 보스 HP 바 없음(타겟 패널 슬라이더만)".
//
// ■ 데이터 출처
// 811b 의 WL.Combat.Core.CombatEvents 를 **구독만** 한다 — Spawned(isBoss) / HitConfirmed / Killed.
// 발주서 §1-2: "이벤트 미도착 시 폴링 폴백 금지 대신 「미표시」" → 이벤트가 오지 않으면 바는 숨은 채로 둔다.
// (Update 에서 Actor 를 찾아다니는 폴백을 두지 않는다. 이벤트 배선 문제를 UI 가 가리면 안 된다.)
//
// ■ 값
// 크기 · 여백 · 눈금 위치 · 색 · 사라짐 지연은 전부 WLHudLayoutSettings 에셋(C45).
// 페이즈 **전투 임계값** 자체는 813h(Gameplay)가 테이블로 갖는다 — 여기 값은 **표시용 눈금**이다.
//
// ■ 숨김 방식
// GameObject 를 끄지 않는다(꺼지면 OnEnable 이 안 돌아 구독이 끊긴다).
// CanvasGroup alpha/blocksRaycasts 로만 숨긴다.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Core;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class BossHpBar : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform bar;
[SerializeField] private Image background;
[SerializeField] private Image fill;
[SerializeField] private RectTransform tickRoot;
[SerializeField] private TextMeshProUGUI nameLabel;
[Header("이름 라벨 폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset nameFont;
[Tooltip("이름 라벨을 표시한다")]
[SerializeField] private bool showName = true;
private RectTransform _rt;
private Canvas _canvas;
private Actor _boss;
private bool _bound;
private float _maxHp, _curHp;
private float _hideAt = -1f;
private bool _subscribed;
/// 마지막 갱신 결과 — 프로브(에디터)가 읽는 진단 값.
public float LastRatio { get; private set; }
public int SpawnedSeen { get; private set; }
public int HitSeen { get; private set; }
public int KilledSeen { get; private set; }
public bool Visible { get { return group != null && group.alpha > 0f; } }
private void Awake() { _rt = GetComponent(); }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
///
/// OnEnable 본체. 에디트 모드에서는 OnEnable 이 불리지 않으므로 검증 스크립트가 이걸 직접 부른다
/// (런타임과 **완전히 같은 경로**를 타야 검증이 의미가 있다).
///
public string Initialize()
{
if (_rt == null) _rt = GetComponent();
BuildIfNeeded();
string layout = ApplyLayout();
HideNow();
Subscribe(true);
return "initialized subscribed=" + _subscribed + " · " + layout;
}
/// 구독 등록/해제. OnEnable/OnDisable 이 부르고, 에디터 검증도 같은 것을 쓴다.
public void Subscribe(bool on)
{
if (on == _subscribed) return;
_subscribed = on;
if (on)
{
CombatEvents.Spawned.Add(OnSpawned);
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
CombatEvents.Killed.Add(OnKilled);
}
else
{
CombatEvents.Spawned.Remove(OnSpawned);
CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
CombatEvents.Killed.Remove(OnKilled);
}
}
private void Update()
{
if (_hideAt >= 0f && Time.unscaledTime >= _hideAt) HideNow();
}
// ── 이벤트 구독 (811b CombatEvents) ───────────────────────────────────
private void OnSpawned(in SpawnedEvent e)
{
SpawnedSeen++;
if (!e.isBoss) return;
Bind(e.actor, null, DebugFallbackMaxHp);
}
private void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!IsTracked(e.victim)) return;
HitSeen++;
float hp = ReadHp(e.victim);
_curHp = hp >= 0f ? hp : Mathf.Max(0f, _curHp - (float)e.damage);
SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f);
}
private void OnKilled(in KilledEvent e)
{
if (!IsTracked(e.victim)) return;
KilledSeen++;
_curHp = 0f;
SetRatio(0f);
var s = WLHudLayoutSettings.Instance;
_hideAt = Time.unscaledTime + (s != null ? s.bossBarHideDelaySec : 0f);
}
// 합성(에디터 검증) 이벤트는 actor 가 null 이다 — null == null 로 같은 경로를 탄다.
private bool IsTracked(Actor a) { return _bound && ReferenceEquals(a, _boss); }
private static float ReadHp(Actor a)
{
if (a == null) return -1f;
var st = a.Get_StatInfo();
if (st == null) return -1f;
return (float)st.Get_Stat(eStat.HP);
}
private static float ReadMaxHp(Actor a)
{
if (a == null) return -1f;
var st = a.Get_StatInfo();
if (st == null) return -1f;
return (float)st.Get_Stat(eStat.MaxHP);
}
/// 보스를 바에 물린다. actor 가 null 이면 합성 검증 모드(최대 HP 는 fallbackMaxHp).
public string Bind(Actor actor, string displayName = null, float fallbackMaxHp = 100f)
{
var s = WLHudLayoutSettings.Instance;
if (s != null && !s.bossBarEnabled) return "bossBarEnabled=false — 미표시";
_boss = actor;
_bound = true;
_hideAt = -1f;
float max = ReadMaxHp(actor);
_maxHp = max > 0f ? max : Mathf.Max(1f, fallbackMaxHp);
float cur = ReadHp(actor);
_curHp = cur >= 0f ? cur : _maxHp;
if (nameLabel != null)
{
string n = displayName;
if (string.IsNullOrEmpty(n)) n = actor != null ? actor.name : "";
nameLabel.text = n;
nameLabel.gameObject.SetActive(showName && !string.IsNullOrEmpty(n));
}
ApplyLayout();
ShowNow();
SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f);
return "bound name=" + (nameLabel != null ? nameLabel.text : "-") +
" maxHp=" + _maxHp.ToString("F0") + " ratio=" + LastRatio.ToString("F3");
}
/// 바 채움 비율(0~1). 눈금은 고정이고 fill 만 움직인다.
public void SetRatio(float ratio)
{
LastRatio = Mathf.Clamp01(ratio);
if (fill != null) fill.fillAmount = LastRatio;
}
private void ShowNow()
{
if (group == null) return;
group.alpha = 1f; group.blocksRaycasts = false; group.interactable = false;
}
private void HideNow()
{
_bound = false; _boss = null; _hideAt = -1f;
if (group == null) return;
group.alpha = 0f; group.blocksRaycasts = false; group.interactable = false;
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
/// 없는 자식만 만든다(에디터 오소링과 런타임이 같은 코드를 쓴다). 이미 있으면 아무 것도 하지 않는다.
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent();
bool made = false;
if (group == null) { group = GetComponent(); if (group == null) { group = gameObject.AddComponent(); made = true; } }
if (bar == null)
{
bar = NewChild(_rt, "Bar");
made = true;
}
if (background == null)
{
var bg = NewChild(bar, "Bg");
Stretch(bg);
background = bg.gameObject.AddComponent();
background.raycastTarget = false;
made = true;
}
if (fill == null)
{
var f = NewChild(bar, "Fill");
Stretch(f);
fill = f.gameObject.AddComponent();
fill.raycastTarget = false;
fill.type = Image.Type.Filled;
fill.fillMethod = Image.FillMethod.Horizontal;
fill.fillOrigin = (int)Image.OriginHorizontal.Left;
fill.fillAmount = 1f;
made = true;
}
if (tickRoot == null)
{
tickRoot = NewChild(bar, "Ticks");
Stretch(tickRoot);
made = true;
}
if (nameLabel == null)
{
var n = NewChild(_rt, "Name");
nameLabel = n.gameObject.AddComponent();
nameLabel.raycastTarget = false;
nameLabel.alignment = TextAlignmentOptions.Center;
nameLabel.text = "";
if (nameFont != null) nameLabel.font = nameFont;
made = true;
}
return made;
}
/// 설정 값(px)을 캔버스 유닛으로 환산해 바·눈금·색을 맞춘다.
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent();
var s = WLHudLayoutSettings.Instance;
if (s == null) return "WLHudLayoutSettings 에셋 없음 — 배치 건너뜀";
if (bar == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = ComputeUnitsPerPx(s);
// 루트 = Safe Area 상단 가로 스트레치 (부모는 SafeAreaFitter 가 붙은 WL_HUD)
_rt.anchorMin = new Vector2(0.5f, 1f);
_rt.anchorMax = new Vector2(0.5f, 1f);
_rt.pivot = new Vector2(0.5f, 1f);
_rt.anchoredPosition = new Vector2(0f, -s.bossBarTopMarginPx * u);
float nameH = s.bossBarNameFontPx + s.bossBarNameGapPx;
_rt.sizeDelta = new Vector2(s.bossBarSizePx.x * u, (s.bossBarSizePx.y + nameH) * u);
_rt.localScale = Vector3.one;
if (nameLabel != null)
{
var nrt = nameLabel.rectTransform;
nrt.anchorMin = new Vector2(0f, 1f); nrt.anchorMax = new Vector2(1f, 1f);
nrt.pivot = new Vector2(0.5f, 1f);
nrt.offsetMin = new Vector2(0f, 0f); nrt.offsetMax = new Vector2(0f, 0f);
nrt.anchoredPosition = Vector2.zero;
nrt.sizeDelta = new Vector2(0f, s.bossBarNameFontPx * u);
nameLabel.fontSize = s.bossBarNameFontPx * u;
nameLabel.color = s.bossBarNameColor;
if (nameFont != null && nameLabel.font != nameFont) nameLabel.font = nameFont;
}
bar.anchorMin = new Vector2(0f, 0f); bar.anchorMax = new Vector2(1f, 0f);
bar.pivot = new Vector2(0.5f, 0f);
bar.anchoredPosition = Vector2.zero;
bar.sizeDelta = new Vector2(0f, s.bossBarSizePx.y * u);
if (background != null) background.color = s.bossBarBgColor;
if (fill != null) fill.color = s.bossBarFillColor;
RebuildTicks(s, u);
return "layout size=" + _rt.sizeDelta + " unitsPerPx=" + u.ToString("F4") +
" ticks=" + (s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0);
}
private void RebuildTicks(WLHudLayoutSettings s, float u)
{
if (tickRoot == null) return;
int want = s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0;
// 남는 눈금은 지운다(설정에서 개수를 줄였을 때).
for (int i = tickRoot.childCount - 1; i >= want; i--)
{
var extra = tickRoot.GetChild(i).gameObject;
if (Application.isPlaying) Destroy(extra); else DestroyImmediate(extra);
}
for (int i = tickRoot.childCount; i < want; i++)
{
var t = NewChild(tickRoot, "Tick_" + i);
var img = t.gameObject.AddComponent();
img.raycastTarget = false;
}
for (int i = 0; i < want; i++)
{
var t = tickRoot.GetChild(i) as RectTransform;
if (t == null) continue;
float at = Mathf.Clamp01(s.bossPhaseTicks[i]);
t.anchorMin = new Vector2(at, 0f);
t.anchorMax = new Vector2(at, 1f);
t.pivot = new Vector2(0.5f, 0.5f);
t.anchoredPosition = Vector2.zero;
t.sizeDelta = new Vector2(s.bossBarTickWidthPx * u, 0f);
t.localScale = Vector3.one;
var img = t.GetComponent();
if (img != null) img.color = s.bossBarTickColor;
}
}
/// px → 캔버스 유닛 계수. 산식은 WLHudLayoutSettings 가 갖는다(전투 패드와 같은 것을 쓴다).
public float ComputeUnitsPerPx(WLHudLayoutSettings s)
{
if (_canvas == null) _canvas = GetComponentInParent