// ───────────────────────────────────────────────────────────────────────────── // 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; using WL.Combat.Run; 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; // ── WL-813q2 — 이번 런에서 바가 실제로 본 보스 HP 비율(결과 화면 「보스 처치」 행이 읽는다) ── // 🔴 813p `RunResult` 에는 보스 HP 필드가 없다(구조체 실측) → 이 값이 UI 자체 실측 원천이다. // −1 = 이번 런에 보스를 한 번도 물지 않았다. public static float RunBossHpRatio = -1f; /// 이번 런에 보스를 한 번이라도 물었는가(진단). public static bool RunBossSeen; /// 런 시작 리셋 횟수(진단 · D-23). public static int RunResetCount; /// 마지막 갱신 결과 — 프로브(에디터)가 읽는 진단 값. 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(); string dedup = DedupInstances(); // WL-813c3 — 중복 바 차단(발주서 후보 ①) HideNow(); Subscribe(true); string late = BindExistingBoss(); // WL-813y — 늦은 구독 보정(Q3 결함 D-3) return "initialized subscribed=" + _subscribed + " · " + layout + " · " + dedup + " · " + late; } /// /// 바가 켜질 때 **이미 살아 있는 보스**가 있으면 즉시 물린다 (WL-813y · Q3 결함 D-3). /// /// 필드 보스는 `FieldBossData.Start()` 로 **맵 로드 시** 스폰돼 `Spawned(isBoss)` 가 /// HUD 가 구독하기 **전에** 지나간다(Q3 실측: `SpawnedSeen=232 · _bound=False`). /// 813d 가 만든 `BossArena.Boss`(게이트가 잡아 둔 현재 보스)를 1순위로 보고, /// 없으면 씬에서 `MobActor` 를 1회만 훑어 `IsSubRole(eSubRol.Boss)` 인 살아 있는 개체를 찾는다. /// public string BindExistingBoss() { var s = WLHudLayoutSettings.Instance; if (s != null && !s.bossBarEnabled) return "늦은구독: bossBarEnabled=false — 생략"; if (s != null && !s.bossBarBindExisting) return "늦은구독: bossBarBindExisting=false — 생략"; if (_bound) return "늦은구독: 이미 물림"; Actor found = null; string how = ""; // ① 813d 보스 아레나가 잡고 있는 보스(게이트 통과 시점에 채워진다) var arenaBoss = WL.Combat.Boss.BossArena.Boss; if (IsAliveBoss(arenaBoss)) { found = arenaBoss; how = "BossArena.Boss"; } // ② 씬 1회 스캔 — 게이트 이전(맵 로드 시) 스폰까지 잡는다 if (found == null) { var mobs = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); LateScanCount = mobs != null ? mobs.Length : 0; for (int i = 0; mobs != null && i < mobs.Length; i++) { if (!IsAliveBoss(mobs[i])) continue; found = mobs[i]; how = "FindObjectsByType " + LateScanCount + "개 스캔"; break; } } if (found == null) return "늦은구독: 살아 있는 보스 없음(스캔 " + LateScanCount + ")"; LateBound = true; return "늦은구독 " + how + " → " + Bind(found, null, DebugFallbackMaxHp); } private static bool IsAliveBoss(Actor a) { if (a == null) return false; if (!a.IsSubRole(eSubRol.Boss)) return false; if (a.IsDead()) return false; return a.gameObject != null && a.gameObject.activeInHierarchy; } /// 늦은 구독 보정으로 물었는가(진단). public bool LateBound { get; private set; } /// 마지막 씬 스캔에서 본 MobActor 수(진단). public int LateScanCount { get; private set; } /// 구독 등록/해제. 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); RunEvents.RunStarted.Add(OnRunStarted); // WL-813q2(D-23) — 다음 런 리셋 } else { CombatEvents.Spawned.Remove(OnSpawned); CombatEvents.HitConfirmed.Remove(OnHitConfirmed); CombatEvents.Killed.Remove(OnKilled); RunEvents.RunStarted.Remove(OnRunStarted); } } // ── WL-813q2(D-23) — 「다음 런」 뒤 이전 런의 보스 바가 남는다 ───────── // `RunDirector.Restart()` 는 존 카운터·게이트만 되돌리고 UI 는 건드리지 않는다(Gameplay 소유). // 그래서 UI 쪽에서 `RunStarted` 를 받아 스스로 리셋하고, 보스가 아직 살아 있으면 // 813y ⑤ 늦은 구독 경로(`BindExistingBoss`)를 그대로 다시 태워 물린다. private void OnRunStarted(in RunStartedEvent e) { ResetForNewRun(); } /// 런 시작 리셋 — 바를 숨기고 물림을 푼 뒤, 살아 있는 보스가 있으면 다시 물린다. public string ResetForNewRun() { var ui = WLRunUiSettings.Instance; if (ui != null && !ui.bossResetOnRunStart) return "런시작리셋: bossResetOnRunStart=false — 생략"; RunResetCount++; RunBossHpRatio = -1f; // 결과 화면이 읽는 런 단위 값도 여기서 비운다 RunBossSeen = false; LateBound = false; HideNow(); // _bound=false · _boss=null · alpha 0 SetRatio(0f); _nextPoll = 0f; if (ui != null && !ui.bossBarRebindOnRunStart) return "런시작리셋: 숨김만(재바인드 off)"; // ── WL-813c2 (Q6 D-23 잔여) ──────────────────────────────────────── // 813q2 의 리셋은 **돌고 있다**. 바가 그대로 남은 것처럼 보이는 실측 원인은 리셋 실패가 아니라 // **곧바로 이어지는 재바인드**다 — `RunDirector.Restart()`(:497) 는 `BossArena.ResetGate()` 만 부르고, // `ResetGate()`(`BossArena.cs:630~638`)는 `s_boss` 를 **비우지 않는다**. 그래서 `BindExistingBoss()` 의 // ① `BossArena.Boss` 가 **이전 런의 살아 있는 보스**를 그대로 돌려주고 바가 즉시 다시 뜬다. // → 이번 런의 보스가 실제로 등장(`BossArena.BossSpawned` · ResetGate 가 false 로 되돌린다)해야만 다시 문다. var hud = WLHudLayoutSettings.Instance; if (hud != null && hud.bossBarRebindRequiresArenaSpawn && !WL.Combat.Boss.BossArena.BossSpawned) return "런시작리셋: 숨김만(이번 런 보스 미등장 · BossArena.BossSpawned=false)"; return "런시작리셋 → " + BindExistingBoss(); } private void Update() { PollBoss(false); if (_hideAt >= 0f && Time.unscaledTime >= _hideAt) HideNow(); } // ── WL-813c2 (Q6 D-26) — 바가 실제 HP 를 안 따라간다 ──────────────────── // 실측 원인(같은 파일): // ① `Bind()`(:311~) 가 `_maxHp` 를 **물릴 때 한 번만** 읽는다. `MyValue.Get_Stat`(`MyValue.cs:993`) // 은 키가 아직 없으면 **0** 을 돌려주므로, 몹 스탯이 채워지기(`MyValue.cs:371`) 전에 물면 // `_maxHp = DebugFallbackMaxHp(100)` 로 굳는다 → `Clamp01(869/100) = 1.000` = **영원히 만피**. // ② 갱신 경로가 `OnHitConfirmed`(:205~) 하나뿐이라 `Actor.Get_Damage` 를 안 타는 HP 변화 // (리쉬 회복 `BossArena.cs:507·512` · 리젠)는 바에 전혀 안 보인다. // ③ 늦은 구독 씬 스캔(:118~)이 **첫 번째** 살아 있는 보스를 물어, 실제로 싸우는 아레나 보스가 // 아닐 수 있다(그러면 HitConfirmed victim 이 한 번도 일치하지 않아 갱신 0). // → 물고 있는 보스의 현재 HP/MaxHP 를 **직접 폴링**해 fill 을 갱신한다. 값·주기·스위치 전부 SO. // 🔴 GC 0 — 이 경로는 문자열을 만들지 않는다(진단은 전부 카운터). /// 폴링 실행 횟수(진단). public int PollCount { get; private set; } /// 폴링이 아레나 보스로 갈아탄 횟수(진단 · 원인 ③). public int PollRebindCount { get; private set; } /// 폴링이 굳어 있던 `_maxHp` 를 고친 횟수(진단 · 원인 ①). public int MaxHpFixCount { get; private set; } /// 폴링이 마지막으로 읽은 현재 HP / 최대 HP(진단). public float PolledHp { get { return _curHp; } } public float PolledMaxHp { get { return _maxHp; } } private float _nextPoll; /// 프로브 전용 — 합성(actor=null) 모드에서 폴링이 읽을 현재 HP(음수 = 쓰지 않음). public static float DebugFakeHp = -1f; /// 프로브 전용 — 합성 모드에서 폴링이 읽을 최대 HP(0 이하 = 쓰지 않음). public static float DebugFakeMaxHp = -1f; /// /// 물고 있는 보스의 HP 를 직접 읽어 fill 을 갱신한다. = 주기 무시(에디트 모드 프로브). /// 돌려주는 값 = 이번 호출에서 실제로 갱신했는가. /// public bool PollBoss(bool force) { var s = WLHudLayoutSettings.Instance; if (s == null || !s.bossBarEnabled || !s.bossBarPollEnabled) return false; float now = Time.unscaledTime; if (!force && s.bossBarPollHz > 0f) { if (now < _nextPoll) return false; _nextPoll = now + 1f / s.bossBarPollHz; } // ③ 아레나가 잡은 보스와 물고 있는 보스가 다르면 그쪽으로 갈아탄다. // 🔴 **물고 있지 않을 때**(= 런 시작 리셋으로 숨긴 상태)는 D-23 을 되살리지 않게 // 이번 런의 보스가 실제로 등장했을 때만 새로 문다(ResetForNewRun 과 같은 조건). if (s.bossBarPollFollowsArena) { var arena = WL.Combat.Boss.BossArena.Boss; if (IsAliveBoss(arena) && !ReferenceEquals(arena, _boss) && (_bound || !s.bossBarRebindRequiresArenaSpawn || WL.Combat.Boss.BossArena.BossSpawned)) { PollRebindCount++; Bind(arena, null, DebugFallbackMaxHp); // Bind 안에서 MaxHP/HP 를 다시 읽는다 return true; } } if (!_bound) return false; if (ReferenceEquals(_boss, null)) return PollSynthetic(); // 합성 검증 모드(actor 없음) if (_boss == null) { HideNow(); return false; } // 파괴된 액터(Unity null) PollCount++; // ① MaxHP 를 매번 다시 읽는다 — 늦게 채워져도 따라간다. float max = ReadMaxHp(_boss); if (max > 0f && max != _maxHp) { _maxHp = max; MaxHpFixCount++; } // ② 현재 HP 도 매번 다시 읽는다 — 피해·회복·리젠 전부 반영된다. float cur = ReadHp(_boss); if (cur >= 0f) _curHp = Mathf.Max(0f, cur); SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f); if (s.bossBarPollHidesOnDeath && _boss.IsDead()) { SetRatio(0f); if (_hideAt < 0f) _hideAt = now + s.bossBarHideDelaySec; } else { // WL-813c3 — HP 가 0 인데 사망 판정이 아직 안 온 프레임에도 바를 내린다(발주서 §1-2). var b = WLBossUiSettings.Instance; if (b != null && b.bossBarHideOnZeroHp && _maxHp > 0f && _curHp <= 0f) { ZeroHpHideCount++; if (_hideAt < 0f) _hideAt = now + s.bossBarHideDelaySec; } } return true; } /// HP 0 숨김이 걸린 프레임 수(진단 · WL-813c3). public int ZeroHpHideCount { get; private set; } /// 합성 모드 폴링 — 프로브가 넣은 가짜 HP 로 같은 산식을 태운다(게임 경로는 −1 이라 무동작). private bool PollSynthetic() { if (DebugFakeHp < 0f && DebugFakeMaxHp <= 0f) return false; PollCount++; if (DebugFakeMaxHp > 0f && DebugFakeMaxHp != _maxHp) { _maxHp = DebugFakeMaxHp; MaxHpFixCount++; } if (DebugFakeHp >= 0f) _curHp = Mathf.Max(0f, DebugFakeHp); SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f); // WL-813c3 — 실액터 경로(PollBoss)와 같은 HP 0 숨김을 합성 경로에도 태운다(검증이 같은 코드를 봐야 한다). var bs = WLBossUiSettings.Instance; if (bs != null && bs.bossBarHideOnZeroHp && _maxHp > 0f && _curHp <= 0f) { ZeroHpHideCount++; var hs = WLHudLayoutSettings.Instance; if (_hideAt < 0f) _hideAt = Time.unscaledTime + (hs != null ? hs.bossBarHideDelaySec : 0f); } return true; } /// 프로브 전용 — 합성 HP 주입을 지운다(게임 경로 오염 0). public static void ClearFakeHp() { DebugFakeHp = -1f; DebugFakeMaxHp = -1f; } // ── 이벤트 구독 (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); } // ── WL-813w §1-4 (Q4b D-12): HP 바 라벨이 `Anubis(Clone)` 로 뜬다 ─────────── // 등장 배너(813tj)는 MonsterList 이름("보스 몬스터 6")을 쓰는데 HP 바만 gameObject.name 을 썼다. // 같은 경로(BossBanner.ResolveMonsterName = MonsterList.n_MonsterName → localtext)를 쓰고, // 표를 못 읽을 때만 오브젝트 이름으로 떨어지되 `(Clone)` 접미사를 지운다. // 이 경로는 Spawned 구독·늦은 구독(813y ⑤) **양쪽이 지나는 Bind() 안**이라 두 경우 모두 고쳐진다. // C8 롤백 = WLBossArenaSettings.bossNameFromTable = 0 → 원본 gameObject.name 그대로. /// 마지막 라벨을 어느 경로로 얻었는가(진단 · "테이블"/"오브젝트이름"/"없음"). public static string LastNameSource = ""; /// 보스 라벨 결정 — 테이블 이름 우선, 폴백은 `(Clone)` 을 지운 오브젝트 이름. public static string ResolveBossLabel(Actor actor) { return ResolveBossLabel(actor, WL.Combat.Boss.WLBossArenaSettings.Instance); } /// 설정을 직접 받는 오버로드(프로브가 임시 SO 로 A/B 한다 · 에셋을 건드리지 않는다). public static string ResolveBossLabel(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena) { bool useTable = arena == null || arena.bossNameFromTable; if (useTable) { string t = ResolveTableName(actor, arena); if (!string.IsNullOrEmpty(t)) { LastNameSource = "테이블"; return t; } } if (actor == null) { LastNameSource = "없음"; return ""; } string n = actor.name; if (!useTable) { LastNameSource = "오브젝트이름(원본)"; return n; } LastNameSource = "오브젝트이름"; return StripClone(n); } /// `Anubis(Clone)` · `Anubis (Clone)` → `Anubis`. public static string StripClone(string n) { if (string.IsNullOrEmpty(n)) return n; int i = n.IndexOf("(Clone)", System.StringComparison.Ordinal); if (i < 0) return n; return n.Substring(0, i).TrimEnd(); } /// MonsterList 이름 — 액터의 스포너 표(MonsterAppear)에서 몬스터 ID 를 얻고, 없으면 SO 의 bossMonsterId. private static string ResolveTableName(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena) { try { if (actor != null) { var ap = actor.Get_MonsterStatTableData(); // MobActor.cs:635 (public virtual) if (ap != null && ap.Get_MobTypeCount() > 0) { var ids = ap.Get_MobIds(); if (ids != null && ids.Count > 0) { string byRow = BossBanner.ResolveMonsterName(ids[0]); if (!string.IsNullOrEmpty(byRow)) return byRow; } } } if (arena != null && arena.bossMonsterId > 0) return BossBanner.ResolveMonsterName(arena.bossMonsterId); } catch { } // 표 미로드(에디트 모드 등)에서 연출이 전투를 끊지 않게 — 813tj 와 같은 규약 return null; } /// 보스를 바에 물린다. 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 = ResolveBossLabel(actor); 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; // WL-813q2 — 물고 있는 동안만 런 단위 값을 남긴다(숨김·리셋 뒤의 0 은 반영하지 않는다). if (_bound) { RunBossHpRatio = LastRatio; RunBossSeen = true; } } 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; } EnsureFillRenderable(); // WL-813c3 — 이미 저장돼 있던 노드도 여기서 고친다 return made; } // ── WL-813c3 (Q7 D-26 「계기는 맞고 화면은 틀리다」) ────────────────────── // 실측: 프리팹에 저장된 `IngameUIs/WL_HUD/WL_BossHpBar/Bar/Fill` 의 Image 는 // `m_Type: 3(Filled) · m_FillMethod: 0(Horizontal) · m_FillAmount: 1` 인데 // **`m_Sprite: {fileID: 0}` = 비어 있다**. 위 BuildIfNeeded():499 가 AddComponent 만 하고 // 스프라이트를 한 번도 안 물렸기 때문이다. // uGUI `Image.OnPopulateMesh` 는 맨 앞에서 `if (activeSprite == null) { base.OnPopulateMesh(toFill); return; }` // 로 빠진다 — 즉 스프라이트가 없으면 `type`·`fillAmount` 를 **아예 읽지 않고** 사각형 전체를 // m_Color 로 채운다. 그래서 계기 `fillAmount=0.368` 이 정확해도 화면은 만피 빨강이었다 // (Q7 `PD4_03_bossbar.png` · `PD4_06_timeout_hp1.png` · 3칸은 그 위에 얹힌 Tick_0/Tick_1 선). // → 바가 켜질 때마다 스프라이트와 Filled 설정을 강제한다. 값·스위치는 전부 WLBossUiSettings(C8/C45). // 🔴 GC: Image 세터는 값이 같으면 되돌아가므로 재적용 비용 0. 흰 스프라이트는 **정적 1개**만 만든다. private static Sprite s_whiteSprite; /// 내장 흰 텍스처로 만든 공유 스프라이트(할당 1회 · 에셋 저장 안 됨). public static Sprite WhiteSprite { get { if (s_whiteSprite == null) { var tex = Texture2D.whiteTexture; s_whiteSprite = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect); s_whiteSprite.name = "WL_BossBarFill_White"; s_whiteSprite.hideFlags = HideFlags.HideAndDontSave; // 프리팹/씬에 굳지 않는다 } return s_whiteSprite; } } /// fill 이 실제로 잘려 그려지도록 스프라이트·Filled 설정을 강제한다(D-26 본체 수리). public string EnsureFillRenderable() { if (fill == null) return "fill 없음 — BuildIfNeeded 먼저"; var b = WLBossUiSettings.Instance; if (b == null) return "WLBossUiSettings 에셋 없음 — 건너뜀"; if (!b.bossBarForceFilledFill) return "bossBarForceFilledFill=false — 건너뜀(813c2 상태)"; fill.type = Image.Type.Filled; fill.fillMethod = Image.FillMethod.Horizontal; fill.fillOrigin = (int)Image.OriginHorizontal.Left; fill.fillClockwise = true; fill.preserveAspect = false; // 바는 rect 를 꽉 채운다(스프라이트 비율 무시) fill.raycastTarget = false; Sprite sp = b.bossBarFillSprite; if (sp == null && b.bossBarFillWhiteFallback) sp = WhiteSprite; if (sp != null && fill.sprite != sp) fill.sprite = sp; if (b.bossBarSpriteOnBgAndTicks && sp != null) { if (background != null && background.sprite != sp) background.sprite = sp; if (tickRoot != null) for (int i = 0; i < tickRoot.childCount; i++) { var img = tickRoot.GetChild(i).GetComponent(); if (img != null && img.sprite != sp) img.sprite = sp; } } return "fill type=" + fill.type + " method=" + fill.fillMethod + " origin=" + fill.fillOrigin + " sprite=" + (fill.sprite != null ? fill.sprite.name : "(없음)") + " amount=" + fill.fillAmount.ToString("F3"); } // ── WL-813c3 — 중복 노드 차단 ───────────────────────────────────────── // 발주서 후보 ①(바가 2벌이라 안 보이는 쪽만 갱신) 검증용. 실측상 프리팹에는 1벌뿐이지만, // 프로브·에디터 도구가 런타임에 하나 더 만들면 뒤쪽 만피 바가 그대로 보인다 → 첫 1개만 남긴다. private static readonly System.Collections.Generic.List s_barBuf = new System.Collections.Generic.List(4); /// 같은 캔버스(없으면 계층 루트) 아래에서 발견한 BossHpBar 개수(진단). public int BarInstanceCount { get; private set; } /// 중복이라 이 인스턴스가 숨겨졌는가(진단). public bool DedupHidden { get; private set; } /// 중복 BossHpBar 를 첫 1개만 남기고 숨긴다. 돌려주는 값 = 진단 문자열. public string DedupInstances() { var b = WLBossUiSettings.Instance; if (b == null) return "중복차단: WLBossUiSettings 에셋 없음 — 생략"; if (!b.bossBarDedupInstances) return "중복차단: bossBarDedupInstances=false — 생략"; if (_canvas == null) _canvas = GetComponentInParent(true); Transform scope = _canvas != null ? _canvas.transform : transform.root; if (scope == null) return "중복차단: 범위 없음 — 생략"; s_barBuf.Clear(); scope.GetComponentsInChildren(true, s_barBuf); BarInstanceCount = s_barBuf.Count; if (BarInstanceCount <= 1) { DedupHidden = false; return "중복차단: 1벌 — 조치 없음"; } BossHpBar keep = s_barBuf[0]; int hidden = 0; for (int i = 1; i < s_barBuf.Count; i++) { var extra = s_barBuf[i]; if (extra == null || ReferenceEquals(extra, keep)) continue; extra.DedupHidden = true; extra.HideNow(); hidden++; } DedupHidden = !ReferenceEquals(this, keep); return "중복차단: " + BarInstanceCount + "벌 발견 → 유지 " + keep.name + " · 숨김 " + hidden + "벌"; } /// 설정 값(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); // WL-813c3 — 눈금을 다시 만든 뒤에 렌더 설정을 강제한다(새 Tick 에도 적용되도록 순서가 중요). string render = EnsureFillRenderable(); return "layout size=" + _rt.sizeDelta + " unitsPerPx=" + u.ToString("F4") + " ticks=" + (s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0) + " · " + render; } 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 + " 늦은구독=" + LateBound + "(스캔 " + LateScanCount + " · BossArena.Boss=" + (WL.Combat.Boss.BossArena.Boss != null ? WL.Combat.Boss.BossArena.Boss.name : "없음") + ")"); sb.AppendLine(" [813c2] 폴링 " + PollCount + "회 · MaxHP 보정 " + MaxHpFixCount + "회 · 아레나 재바인드 " + PollRebindCount + "회 · hp=" + PolledHp.ToString("F0") + "/" + PolledMaxHp.ToString("F0") + " · BossArena.BossSpawned=" + WL.Combat.Boss.BossArena.BossSpawned + " · 설정 " + (s == null ? "(에셋 없음)" : "poll=" + s.bossBarPollEnabled + "@" + s.bossBarPollHz.ToString("F0") + "Hz" + " 아레나추종=" + s.bossBarPollFollowsArena + " 사망숨김=" + s.bossBarPollHidesOnDeath + " 재바인드조건=" + s.bossBarRebindRequiresArenaSpawn)); sb.AppendLine(" [813q2] 런 리셋 " + RunResetCount + "회 · 런 보스 HP=" + (RunBossHpRatio >= 0f ? (RunBossHpRatio * 100f).ToString("F1") + "%" : "없음") + " (본적=" + RunBossSeen + ") · RunStarted 구독=" + RunEvents.RunStarted.Count); 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)); // ── WL-813c3 — D-26 「화면」 진단: fillAmount 가 실제 메쉬에 반영되는 조건 ── if (_canvas == null) _canvas = GetComponentInParent(true); var bset = WLBossUiSettings.Instance; sb.AppendLine(" [813c3] fill " + (fill == null ? "(없음)" : "type=" + fill.type + " method=" + fill.fillMethod + " origin=" + fill.fillOrigin + " amount=" + fill.fillAmount.ToString("F3") + " sprite=" + (fill.sprite != null ? fill.sprite.name : "🔴(없음)=fillAmount 무효") + " enabled=" + fill.enabled + " color=" + ColorUtility.ToHtmlStringRGBA(fill.color) + " active=" + fill.gameObject.activeInHierarchy) + " · 바 " + BarInstanceCount + "벌(이 인스턴스 숨김=" + DedupHidden + ")" + " · HP0숨김 " + ZeroHpHideCount + "회" + " · 설정 " + (bset == null ? "(에셋 없음)" : "force=" + bset.bossBarForceFilledFill + " white=" + bset.bossBarFillWhiteFallback + " spriteSO=" + (bset.bossBarFillSprite != null ? bset.bossBarFillSprite.name : "없음") + " dedup=" + bset.bossBarDedupInstances + " hp0숨김=" + bset.bossBarHideOnZeroHp)); sb.AppendLine(" [813c3] canvas " + (_canvas == null ? "(없음)" : _canvas.name + " order=" + _canvas.sortingOrder + " override=" + _canvas.overrideSorting + " enabled=" + _canvas.enabled + " renderMode=" + _canvas.renderMode)); 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(); } } }