// ───────────────────────────────────────────────────────────────────────────── // 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(true); return s.UnitsPerPx(_canvas); } private static RectTransform NewChild(RectTransform parent, string childName) { var go = new GameObject(childName, typeof(RectTransform)); go.layer = parent != null ? parent.gameObject.layer : 5; var rt = (RectTransform)go.transform; rt.SetParent(parent, false); rt.localScale = Vector3.one; return rt; } private static void Stretch(RectTransform rt) { rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; rt.pivot = new Vector2(0.5f, 0.5f); } /// 에디터가 폰트를 주입한다(런타임 로드 없음). public void SetNameFont(TMP_FontAsset font) { nameFont = font; if (nameLabel != null && font != null) nameLabel.font = font; } // ── 검증 전용 (발주서 ⓒ) ───────────────────────────────────────────── /// /// 가짜 전투 이벤트를 **실제 CombatEvents 경로로** 발행한다(Assembly-CSharp 내부라 Dispatch 접근 가능). /// Play 없이 구독 → 갱신을 확인하는 용도. actor 가 없으므로 페이로드의 actor/victim 은 null 이다. /// public static float DebugFallbackMaxHp = 100f; // 합성 이벤트 전용(actor 가 없을 때의 최대 HP) public static string RaiseFakeBossSpawned(float maxHp) { DebugFallbackMaxHp = Mathf.Max(1f, maxHp); var e = new SpawnedEvent { actor = null, isBoss = true, isElite = false, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount }; CombatEvents.Spawned.Dispatch(in e); return "dispatched Spawned(isBoss=true) subscribers=" + CombatEvents.Spawned.Count; } /// 비보스 스폰 — 바가 반응하지 않아야 한다(회귀 확인). public static string RaiseFakeNonBossSpawned() { var e = new SpawnedEvent { actor = null, isBoss = false, isElite = false, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount }; CombatEvents.Spawned.Dispatch(in e); return "dispatched Spawned(isBoss=false) subscribers=" + CombatEvents.Spawned.Count; } public static string RaiseFakeHit(double damage) { var e = new HitConfirmedEvent { victim = null, attacker = null, dinfo = null, damage = damage, critical = false, isKill = false, byMainPC = true, time = Time.unscaledTime, frame = Time.frameCount }; CombatEvents.HitConfirmed.Dispatch(in e); return "dispatched HitConfirmed(dmg=" + damage + ") subscribers=" + CombatEvents.HitConfirmed.Count; } public static string RaiseFakeKilled() { var e = new KilledEvent { victim = null, killer = null, id = 0, position = Vector3.zero, byDirectHit = true, time = Time.unscaledTime, frame = Time.frameCount }; CombatEvents.Killed.Dispatch(in e); return "dispatched Killed subscribers=" + CombatEvents.Killed.Count; } /// 현재 상태 덤프. 발주서 ⓒ 증거용. public string Dump() { var sb = new StringBuilder(); if (_rt == null) _rt = GetComponent(); var s = WLHudLayoutSettings.Instance; sb.AppendLine("[BossHpBar] subscribed=" + _subscribed + " visible=" + Visible + " ratio=" + LastRatio.ToString("F3") + " hp=" + _curHp.ToString("F0") + "/" + _maxHp.ToString("F0") + " seen(Spawned/Hit/Killed)=" + SpawnedSeen + "/" + HitSeen + "/" + KilledSeen); sb.AppendLine(" CombatEvents subscribers Spawned=" + CombatEvents.Spawned.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count + " Killed=" + CombatEvents.Killed.Count + " Enabled=" + CombatEvents.Enabled); sb.AppendLine(" root " + (_rt != null ? "pos" + _rt.anchoredPosition + " size" + _rt.sizeDelta + " aMin" + _rt.anchorMin + " aMax" + _rt.anchorMax : "(없음)")); sb.AppendLine(" bar " + (bar != null ? "size" + bar.sizeDelta + " aMin" + bar.anchorMin + " aMax" + bar.anchorMax : "(없음)") + " fill=" + (fill != null ? fill.fillAmount.ToString("F3") : "-") + " ticks=" + (tickRoot != null ? tickRoot.childCount : 0)); if (tickRoot != null) for (int i = 0; i < tickRoot.childCount; i++) { var t = tickRoot.GetChild(i) as RectTransform; if (t != null) sb.AppendLine(" " + t.name + " at=" + t.anchorMin.x.ToString("F3") + " w=" + t.sizeDelta.x.ToString("F1")); } sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" : "sizePx=" + s.bossBarSizePx + " topMarginPx=" + s.bossBarTopMarginPx + " ticks=" + (s.bossPhaseTicks != null ? string.Join(",", System.Array.ConvertAll(s.bossPhaseTicks, v => v.ToString("F2"))) : "-"))); return sb.ToString(); } } }