diff --git a/AgentScripts/WL813c3_PROBE.txt b/AgentScripts/WL813c3_PROBE.txt new file mode 100644 index 000000000..94d98ef60 --- /dev/null +++ b/AgentScripts/WL813c3_PROBE.txt @@ -0,0 +1,108 @@ +# WL813c3 Probe 2026-09-09 16:07:48 (edit mode · Play False) + 프리팹 전 = 5361935 B · 2026-09-08 17:05:53.269 UTC + +── ① 프리팹에 저장된 그대로 (수리 전 · Initialize 호출 0) + BossHpBar 인스턴스 = 1벌 + 경로 = NewGameUI/IngameUIs/WL_HUD/WL_BossHpBar · 소속 캔버스 = NewGameUI(order=0 override=False enabled=True mode=ScreenSpaceCamera) + - WL_BossHpBar active=True/False parent=WL_HUD [CanvasGroup alpha=0.00] [BossHpBar] + - Bar active=True/False parent=WL_BossHpBar + - Bg active=True/False parent=Bar [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#0D0D0FC7 raycast=False] 메쉬폭비=1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + - Fill active=True/False parent=Bar [Image type=Filled method=Horizontal origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FC4247FF raycast=False] 메쉬폭비=1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + - Ticks active=True/False parent=Bar + - Tick_0 active=True/False parent=Ticks [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FFFFFFD9 raycast=False] 메쉬폭비=1.000 (verts=4 x[-3.6,3.6] rectW=7.1) + - Tick_1 active=True/False parent=Ticks [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FFFFFFD9 raycast=False] 메쉬폭비=1.000 (verts=4 x[-3.6,3.6] rectW=7.1) + - Name active=True/False parent=WL_BossHpBar [TMP "" fs=53.3] + [PASS] Bar/Fill Image 존재 + [PASS] 🔴 D-26 원인 재현: 저장된 Fill 의 sprite = 없음 (BossHpBar.cs:499 가 AddComponent 만 했다) + [PASS] 저장된 Fill 의 type = Filled (후보 ③ 「type 이 Filled 가 아니다」는 반증됨) + [PASS] 바 노드 1벌뿐 (후보 ① 「2벌」 반증) + +── ② 메쉬 실측 (수리 전) — fillAmount 를 Q7 D-26 그 값 0.368 로 두고 실제 정점을 만든다 + fillAmount=0.368 → 메쉬폭비 = 1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + [PASS] 🔴 D-26 기계적 증거: sprite 가 없어 fillAmount 0.368 인데 메쉬는 폭비 1.000 = 만피 사각형 + +── ③ 수리 — BossHpBar.Initialize() (런타임 OnEnable 과 같은 경로) + Initialize → initialized subscribed=True · layout size=(1564.44, 117.33) unitsPerPx=1.7778 ticks=2 · fill type=Filled method=Horizontal origin=0 sprite=WL_BossBarFill_White amount=0.368 · 중복차단: 1벌 — 조치 없음 · 늦은구독: 살아 있는 보스 없음(스캔 0) + [PASS] 고침: Fill.sprite = WL_BossBarFill_White + [PASS] 고침: type=Filled · method=Horizontal · origin=Left 강제 + +── ④ 메쉬 실측 (수리 후) — 같은 fillAmount 0.368 + fillAmount=0.368 → 메쉬폭비 = 0.368 (verts=4 x[-782.2,-206.5] rectW=1564.4) + [PASS] 화면 추종: 메쉬폭비 0.368 ≈ fillAmount 0.368 (오차 0.0000) + +── ⑤ 가짜 HP 1800 → 869 (발주서 §1-2) — 화면 노드가 따라오는가 + dispatched Spawned(isBoss=true) subscribers=1 + 스폰 직후 ratio=1.000 fillAmount=1.000 + HP 869/1800 → LastRatio=0.483 · fillAmount=0.483 · 메쉬폭비=0.483 (verts=4 x[-782.2,-26.9] rectW=1564.4) + [PASS] 계기 LastRatio = 0.483 + [PASS] fillAmount = 0.483 + [PASS] 🔴 화면 노드 메쉬폭비 = 0.483 (발주서 §1-2 목표 0.483) + [PASS] 바가 보이는 상태(CanvasGroup alpha > 0) + 눈금 2개 · Ticks 형제 순서 = 2 / Fill = 1 + [PASS] 3칸 = fill 위에 얹힌 구분선 2개(Fill 뒤 형제) — 별도 세그먼트 이미지 아님 + +── ⑥ C8 롤백 — bossBarForceFilledFill = 0 → 813c2 상태(화면 만피)로 복귀 + EnsureFillRenderable → bossBarForceFilledFill=false — 건너뜀(813c2 상태) + 롤백 상태 fillAmount=0.483 → 메쉬폭비 = 1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + [PASS] 롤백 시 D-26 재현(메쉬폭비 1.000) = 스위치 하나로 되돌아간다(C8) + 복원 → fill type=Filled method=Horizontal origin=0 sprite=WL_BossBarFill_White amount=0.483 + +── ⑦ 보스 HP 0 → 숨김 예약 + HP 0 → ratio=0.000 · ZeroHpHideCount 0→1 + [PASS] HP 0 → ratio 0 + [PASS] HP 0 숨김 예약 1회 (bossBarHideDelaySec 1.2s 뒤) + [PASS] C8: bossBarHideOnZeroHp=0 → 예약 생략 + +── ⑧ 중복 노드 차단 — 같은 캔버스에 바를 하나 더 만들어 본다 + 중복 Initialize → initialized subscribed=True · layout size=(1564.44, 117.33) unitsPerPx=1.7778 ticks=2 · fill type=Filled method=Horizontal origin=0 sprite=WL_BossBarFill_White amount=1.000 · 중복차단: 2벌 발견 → 유지 WL_BossHpBar · 숨김 1벌 · 늦은구독: 살아 있는 보스 없음(스캔 0) + 원본 BarInstanceCount=1 · 중복 숨김=True · 중복 Visible=False + [PASS] 중복 감지 2벌 + [PASS] 중복 바는 숨김(alpha 0) + C8: bossBarDedupInstances=0 → 중복차단: bossBarDedupInstances=false — 생략 + +── ⑨ GC — 폴링 20,000 회 할당 + GC 1162354688 → 1162354688 (Δ 0 B) + [PASS] 폴링 20,000회 GC 증가 0 B (Δ 0) + EnsureFillRenderable 20,000회: GC 1162354688 → 1167208448 (Δ 4853760 B · 문자열 반환 포함) + +── ⑩ 수리 후 노드 전수 (HP 869/1800) + BossHpBar 인스턴스 = 2벌 + 경로 = NewGameUI/IngameUIs/WL_HUD/WL_BossHpBar · 소속 캔버스 = NewGameUI(order=0 override=False enabled=True mode=ScreenSpaceCamera) + - WL_BossHpBar active=True/False parent=WL_HUD [CanvasGroup alpha=1.00] [BossHpBar] + - Bar active=True/False parent=WL_BossHpBar + - Bg active=True/False parent=Bar [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#0D0D0FC7 raycast=False] 메쉬폭비=1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + - Fill active=True/False parent=Bar [Image type=Filled method=Horizontal origin=0 amount=0.483 sprite=WL_BossBarFill_White enabled=True color=#FC4247FF raycast=False] 메쉬폭비=0.483 (verts=4 x[-782.2,-26.9] rectW=1564.4) + - Ticks active=True/False parent=Bar + - Tick_0 active=True/False parent=Ticks [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FFFFFFD9 raycast=False] 메쉬폭비=1.000 (verts=4 x[-3.6,3.6] rectW=7.1) + - Tick_1 active=True/False parent=Ticks [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FFFFFFD9 raycast=False] 메쉬폭비=1.000 (verts=4 x[-3.6,3.6] rectW=7.1) + - Name active=False/False parent=WL_BossHpBar [TMP "" fs=53.3] + 경로 = NewGameUI/IngameUIs/WL_HUD/WL_BossHpBar_DUP · 소속 캔버스 = NewGameUI(order=0 override=False enabled=True mode=ScreenSpaceCamera) + - WL_BossHpBar_DUP active=True/False parent=WL_HUD [CanvasGroup alpha=0.00] [BossHpBar] + - Bar active=True/False parent=WL_BossHpBar_DUP + - Bg active=True/False parent=Bar [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#0D0D0FC7 raycast=False] 메쉬폭비=1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + - Fill active=True/False parent=Bar [Image type=Filled method=Horizontal origin=0 amount=1.000 sprite=WL_BossBarFill_White enabled=True color=#FC4247FF raycast=False] 메쉬폭비=1.000 (verts=4 x[-782.2,782.2] rectW=1564.4) + - Ticks active=True/False parent=Bar + - Tick_0 active=True/False parent=Ticks [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FFFFFFD9 raycast=False] 메쉬폭비=1.000 (verts=4 x[-3.6,3.6] rectW=7.1) + - Tick_1 active=True/False parent=Ticks [Image type=Simple method=Radial360 origin=0 amount=1.000 sprite=🔴없음 enabled=True color=#FFFFFFD9 raycast=False] 메쉬폭비=1.000 (verts=4 x[-3.6,3.6] rectW=7.1) + - Name active=True/False parent=WL_BossHpBar_DUP [TMP "" fs=53.3] + [BossHpBar] subscribed=True visible=True ratio=0.483 hp=869/1800 seen(Spawned/Hit/Killed)=1/0/0 늦은구독=False(스캔 0 · BossArena.Boss=없음) + [813c2] 폴링 20006회 · MaxHP 보정 0회 · 아레나 재바인드 0회 · hp=869/1800 · BossArena.BossSpawned=False · 설정 poll=True@10Hz 아레나추종=True 사망숨김=True 재바인드조건=True + [813q2] 런 리셋 0회 · 런 보스 HP=48.3% (본적=True) · RunStarted 구독=1 + CombatEvents subscribers Spawned=1 HitConfirmed=1 Killed=1 Enabled=True + root pos(0.00, -337.78) size(1564.44, 117.33) aMin(0.50, 1.00) aMax(0.50, 1.00) + bar size(0.00, 49.78) aMin(0.00, 0.00) aMax(1.00, 0.00) fill=0.483 ticks=2 + [813c3] fill type=Filled method=Horizontal origin=0 amount=0.483 sprite=WL_BossBarFill_White enabled=True color=FC4247FF active=False · 바 1벌(이 인스턴스 숨김=False) · HP0숨김 1회 · 설정 force=True white=True spriteSO=없음 dedup=True hp0숨김=True + [813c3] canvas NewGameUI order=0 override=False enabled=True renderMode=ScreenSpaceCamera + Tick_0 at=0.700 w=7.1 + Tick_1 at=0.400 w=7.1 + 설정 sizePx=(880.00, 28.00) topMarginPx=190 ticks=0.70,0.40 + + +── ⑪ 뒷정리 — 프리팹 저장 0 · 구독 0 + 프리팹 후 = 5361935 B · 2026-09-08 17:05:53.269 UTC + [PASS] 프리팹 파일 무변동 = 저장 0 + CombatEvents Spawned=0 HitConfirmed=0 Killed=0 · RunStarted=0 + SO 원복 = force:True white:True bgTick:False dedup:True hp0:True + DebugFakeHp=-1 DebugFakeMaxHp=-1 DebugFallbackMaxHp=100 + +== 합계 PASS 21 / FAIL 0 == diff --git a/AgentScripts/WL813c3_Probe.cs b/AgentScripts/WL813c3_Probe.cs new file mode 100644 index 000000000..c624cac0b --- /dev/null +++ b/AgentScripts/WL813c3_Probe.cs @@ -0,0 +1,376 @@ +// WL-813c3 프로브 — Q7 결함 D-26 「계기는 맞고 화면은 틀리다」 실측(에디트 모드 · Play 0 · 로그인 0 · #813). +// unity command run_script --file AgentScripts/WL813c3_Probe.cs --entry WL813c3_Probe.RunAll +// 산출물: /AgentScripts/WL813c3_PROBE.txt +// +// 🔴 프리팹 저장 0 — LoadPrefabContents 로 열고 finally 에서 UnloadPrefabContents. SaveAsPrefabAsset 호출 없음. +// 파일 크기·수정시각을 전후로 찍어 "저장 0" 을 증거로 남긴다. +// 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복(AssetDatabase.SaveAssets 0). +// 🔴 Gameplay 는 읽기만. Play 진입 0. +// 🔴 핵심 계측 = **fillAmount 가 아니라 실제로 생성되는 메쉬 폭**. +// uGUI Image.OnPopulateMesh(VertexHelper) 를 리플렉션으로 직접 불러 정점 x 범위를 잰다. +// sprite 가 없으면 Graphic.OnPopulateMesh(사각형 전체) 로 빠지므로 fillAmount 와 무관하게 폭 비율 1.000 이 나온다. +// = D-26 「화면 만피」 의 기계적 증거. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using UnityEngine.UI; +using UnityEditor; +using WL.UI; + +public static class WL813c3_Probe +{ + const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; + const string kHudPath = "IngameUIs/WL_HUD"; + const string kBarName = "WL_BossHpBar"; + + static StringBuilder _o; + static int _pass, _fail; + + static void H(string s) { _o.AppendLine(); _o.AppendLine("── " + s); } + static void N(string s) { _o.AppendLine(" " + s); } + static bool Chk(bool ok, string what) + { + if (ok) { _pass++; _o.AppendLine(" [PASS] " + what); } + else { _fail++; _o.AppendLine(" [FAIL] " + what); } + return ok; + } + + static string Root { get { return Directory.GetParent(Application.dataPath).FullName; } } + static string OutPath(string n) { return Path.Combine(Path.Combine(Root, "AgentScripts"), n); } + + // ── 메쉬 실측 ──────────────────────────────────────────────────────────── + static MethodInfo _opm; + /// Image 가 실제로 만드는 정점의 x 폭 / rect 폭. sprite 없으면 언제나 1.000 이 나온다. + static float MeshWidthRatio(Image img, out int vertexCount, out string note) + { + vertexCount = 0; note = ""; + if (img == null) { note = "Image 없음"; return -1f; } + if (_opm == null) + { + _opm = typeof(Image).GetMethod("OnPopulateMesh", + BindingFlags.Instance | BindingFlags.NonPublic, + null, new Type[] { typeof(VertexHelper) }, null); + if (_opm == null) + _opm = typeof(Graphic).GetMethod("OnPopulateMesh", + BindingFlags.Instance | BindingFlags.NonPublic, + null, new Type[] { typeof(VertexHelper) }, null); + } + if (_opm == null) { note = "OnPopulateMesh 리플렉션 실패"; return -1f; } + + var vh = new VertexHelper(); + try { _opm.Invoke(img, new object[] { vh }); } + catch (Exception e) { note = "invoke 실패: " + e.GetBaseException().Message; vh.Dispose(); return -1f; } + + vertexCount = vh.currentVertCount; + if (vertexCount == 0) { vh.Dispose(); note = "정점 0"; return 0f; } + + float minX = float.MaxValue, maxX = float.MinValue; + var v = new UIVertex(); + for (int i = 0; i < vertexCount; i++) + { + vh.PopulateUIVertex(ref v, i); + if (v.position.x < minX) minX = v.position.x; + if (v.position.x > maxX) maxX = v.position.x; + } + vh.Dispose(); + + float rectW = img.rectTransform.rect.width; + if (rectW <= 0f) { note = "rect 폭 0"; return -1f; } + note = "verts=" + vertexCount + " x[" + minX.ToString("F1") + "," + maxX.ToString("F1") + "] rectW=" + rectW.ToString("F1"); + return (maxX - minX) / rectW; + } + + // ── 노드 전수 덤프 ─────────────────────────────────────────────────────── + static void DumpTree(Transform t, int depth) + { + var sb = new StringBuilder(); + sb.Append(new string(' ', 4 + depth * 2)).Append("- ").Append(t.name); + sb.Append(" active=").Append(t.gameObject.activeSelf).Append("/").Append(t.gameObject.activeInHierarchy); + sb.Append(" parent=").Append(t.parent != null ? t.parent.name : "(없음)"); + + var img = t.GetComponent(); + if (img != null) + { + sb.Append(" [Image type=").Append(img.type) + .Append(" method=").Append(img.fillMethod) + .Append(" origin=").Append(img.fillOrigin) + .Append(" amount=").Append(img.fillAmount.ToString("F3")) + .Append(" sprite=").Append(img.sprite != null ? img.sprite.name : "🔴없음") + .Append(" enabled=").Append(img.enabled) + .Append(" color=#").Append(ColorUtility.ToHtmlStringRGBA(img.color)) + .Append(" raycast=").Append(img.raycastTarget).Append("]"); + int vc; string note; + float r = MeshWidthRatio(img, out vc, out note); + sb.Append(" 메쉬폭비=").Append(r < 0f ? "?" : r.ToString("F3")).Append(" (").Append(note).Append(")"); + } + var cg = t.GetComponent(); + if (cg != null) sb.Append(" [CanvasGroup alpha=").Append(cg.alpha.ToString("F2")).Append("]"); + var cv = t.GetComponent(); + if (cv != null) sb.Append(" [Canvas order=").Append(cv.sortingOrder).Append(" override=").Append(cv.overrideSorting) + .Append(" enabled=").Append(cv.enabled).Append("]"); + var tmp = t.GetComponent(); + if (tmp != null) sb.Append(" [TMP \"").Append(tmp.text).Append("\" fs=").Append(tmp.fontSize.ToString("F1")).Append("]"); + var bhb = t.GetComponent(); + if (bhb != null) sb.Append(" [BossHpBar]"); + + _o.AppendLine(sb.ToString()); + for (int i = 0; i < t.childCount; i++) DumpTree(t.GetChild(i), depth + 1); + } + + static void DumpBarNodes(GameObject root, string title) + { + H(title); + var all = root.GetComponentsInChildren(true); + N("BossHpBar 인스턴스 = " + all.Length + "벌"); + foreach (var b in all) + { + // 부모 사슬 + 캔버스 + var chain = new List(); + var p = b.transform; + Canvas nearest = null; + while (p != null) + { + chain.Add(p.name); + if (nearest == null) { var c = p.GetComponent(); if (c != null) nearest = c; } + p = p.parent; + } + chain.Reverse(); + N("경로 = " + string.Join("/", chain.ToArray()) + + " · 소속 캔버스 = " + (nearest != null ? nearest.name + "(order=" + nearest.sortingOrder + + " override=" + nearest.overrideSorting + " enabled=" + nearest.enabled + " mode=" + nearest.renderMode + ")" : "🔴없음")); + DumpTree(b.transform, 0); + } + } + + // ── 본체 ───────────────────────────────────────────────────────────────── + public static string RunAll() + { + _o = new StringBuilder(); + _pass = _fail = 0; + _o.AppendLine("# WL813c3 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + + " (edit mode · Play " + Application.isPlaying + ")"); + + var boss = WLBossUiSettings.Instance; + var hudS = WLHudLayoutSettings.Instance; + if (boss == null || hudS == null) + { + _o.AppendLine("🔴 설정 에셋 없음 — boss=" + (boss != null) + " hud=" + (hudS != null)); + return Flush(); + } + + // SO 스냅샷(원복용) + bool sForce = boss.bossBarForceFilledFill, sWhite = boss.bossBarFillWhiteFallback; + bool sBgTick = boss.bossBarSpriteOnBgAndTicks, sDedup = boss.bossBarDedupInstances, sZero = boss.bossBarHideOnZeroHp; + + string abs = Path.Combine(Root, kPrefab.Replace('/', Path.DirectorySeparatorChar)); + var fi0 = new FileInfo(abs); + long len0 = fi0.Length; DateTime mt0 = fi0.LastWriteTimeUtc; + N("프리팹 전 = " + len0 + " B · " + mt0.ToString("yyyy-MM-dd HH:mm:ss.fff") + " UTC"); + + GameObject root = null; + BossHpBar bar = null; + GameObject dupGo = null; + try + { + root = PrefabUtility.LoadPrefabContents(kPrefab); + + var hud = root.transform.Find(kHudPath); + if (hud == null) { _o.AppendLine("🔴 " + kHudPath + " 없음"); return Flush(); } + var barT = hud.Find(kBarName); + if (barT == null) { _o.AppendLine("🔴 " + kBarName + " 없음"); return Flush(); } + bar = barT.GetComponent(); + if (bar == null) { _o.AppendLine("🔴 BossHpBar 컴포넌트 없음"); return Flush(); } + + // ── ① 저장된 상태 그대로 (수리 전) ────────────────────────────── + DumpBarNodes(root, "① 프리팹에 저장된 그대로 (수리 전 · Initialize 호출 0)"); + var fillT = barT.Find("Bar/Fill"); + var fillImg = fillT != null ? fillT.GetComponent() : null; + Chk(fillImg != null, "Bar/Fill Image 존재"); + Chk(fillImg != null && fillImg.sprite == null, + "🔴 D-26 원인 재현: 저장된 Fill 의 sprite = 없음 (BossHpBar.cs:499 가 AddComponent 만 했다)"); + Chk(fillImg != null && fillImg.type == Image.Type.Filled, + "저장된 Fill 의 type = Filled (후보 ③ 「type 이 Filled 가 아니다」는 반증됨)"); + Chk(root.GetComponentsInChildren(true).Length == 1, + "바 노드 1벌뿐 (후보 ① 「2벌」 반증)"); + + // ── ② 메쉬 실측 (수리 전 · fillAmount 0.368 = Q7 캡처 그 값) ──── + H("② 메쉬 실측 (수리 전) — fillAmount 를 Q7 D-26 그 값 0.368 로 두고 실제 정점을 만든다"); + if (fillImg != null) + { + fillImg.fillAmount = 0.368f; + int vc; string note; + float r = MeshWidthRatio(fillImg, out vc, out note); + N("fillAmount=0.368 → 메쉬폭비 = " + r.ToString("F3") + " (" + note + ")"); + Chk(r > 0.99f, + "🔴 D-26 기계적 증거: sprite 가 없어 fillAmount 0.368 인데 메쉬는 폭비 " + r.ToString("F3") + " = 만피 사각형"); + } + + // ── ③ 수리 = 런타임과 같은 경로(Initialize) ───────────────────── + H("③ 수리 — BossHpBar.Initialize() (런타임 OnEnable 과 같은 경로)"); + boss.bossBarForceFilledFill = true; boss.bossBarFillWhiteFallback = true; + string init = bar.Initialize(); + N("Initialize → " + init); + Chk(fillImg != null && fillImg.sprite != null, + "고침: Fill.sprite = " + (fillImg != null && fillImg.sprite != null ? fillImg.sprite.name : "(없음)")); + Chk(fillImg != null && fillImg.type == Image.Type.Filled && + fillImg.fillMethod == Image.FillMethod.Horizontal && + fillImg.fillOrigin == (int)Image.OriginHorizontal.Left, + "고침: type=Filled · method=Horizontal · origin=Left 강제"); + + // ── ④ 메쉬 실측 (수리 후) ─────────────────────────────────────── + H("④ 메쉬 실측 (수리 후) — 같은 fillAmount 0.368"); + if (fillImg != null) + { + fillImg.fillAmount = 0.368f; + int vc; string note; + float r = MeshWidthRatio(fillImg, out vc, out note); + N("fillAmount=0.368 → 메쉬폭비 = " + r.ToString("F3") + " (" + note + ")"); + Chk(Mathf.Abs(r - 0.368f) < 0.01f, + "화면 추종: 메쉬폭비 " + r.ToString("F3") + " ≈ fillAmount 0.368 (오차 " + + Mathf.Abs(r - 0.368f).ToString("F4") + ")"); + } + + // ── ⑤ 발주서 §1-2 프로브: 가짜 HP 1800 → 869 → 화면 노드 fill 0.483 ── + H("⑤ 가짜 HP 1800 → 869 (발주서 §1-2) — 화면 노드가 따라오는가"); + BossHpBar.DebugFallbackMaxHp = 1800f; + BossHpBar.DebugFakeMaxHp = 1800f; + BossHpBar.DebugFakeHp = 1800f; + N(BossHpBar.RaiseFakeBossSpawned(1800f)); + bar.PollBoss(true); + N("스폰 직후 ratio=" + bar.LastRatio.ToString("F3") + " fillAmount=" + + (fillImg != null ? fillImg.fillAmount.ToString("F3") : "-")); + + BossHpBar.DebugFakeHp = 869f; + bar.PollBoss(true); + float expect = 869f / 1800f; + int vc2; string note2; + float r2 = MeshWidthRatio(fillImg, out vc2, out note2); + N("HP 869/1800 → LastRatio=" + bar.LastRatio.ToString("F3") + + " · fillAmount=" + (fillImg != null ? fillImg.fillAmount.ToString("F3") : "-") + + " · 메쉬폭비=" + r2.ToString("F3") + " (" + note2 + ")"); + Chk(Mathf.Abs(bar.LastRatio - expect) < 0.002f, "계기 LastRatio = " + expect.ToString("F3")); + Chk(fillImg != null && Mathf.Abs(fillImg.fillAmount - expect) < 0.002f, "fillAmount = " + expect.ToString("F3")); + Chk(Mathf.Abs(r2 - expect) < 0.01f, + "🔴 화면 노드 메쉬폭비 = " + r2.ToString("F3") + " (발주서 §1-2 목표 0.483)"); + Chk(bar.Visible, "바가 보이는 상태(CanvasGroup alpha > 0)"); + + // 3칸 구분선(눈금)이 fill 위에 그대로 얹혀 있는가 + var ticks = barT.Find("Bar/Ticks"); + int tickN = ticks != null ? ticks.childCount : 0; + N("눈금 " + tickN + "개 · Ticks 형제 순서 = " + (ticks != null ? ticks.GetSiblingIndex() : -1) + + " / Fill = " + (fillT != null ? fillT.GetSiblingIndex() : -1)); + Chk(tickN == 2 && ticks != null && fillT != null && ticks.GetSiblingIndex() > fillT.GetSiblingIndex(), + "3칸 = fill 위에 얹힌 구분선 2개(Fill 뒤 형제) — 별도 세그먼트 이미지 아님"); + + // ── ⑥ C8 롤백 재현 ────────────────────────────────────────────── + H("⑥ C8 롤백 — bossBarForceFilledFill = 0 → 813c2 상태(화면 만피)로 복귀"); + boss.bossBarForceFilledFill = false; + if (fillImg != null) fillImg.sprite = null; // 저장된 프리팹과 같은 조건 + N("EnsureFillRenderable → " + bar.EnsureFillRenderable()); + if (fillImg != null) fillImg.fillAmount = 0.483f; + int vc3; string note3; + float r3 = MeshWidthRatio(fillImg, out vc3, out note3); + N("롤백 상태 fillAmount=0.483 → 메쉬폭비 = " + r3.ToString("F3") + " (" + note3 + ")"); + Chk(r3 > 0.99f, "롤백 시 D-26 재현(메쉬폭비 " + r3.ToString("F3") + ") = 스위치 하나로 되돌아간다(C8)"); + boss.bossBarForceFilledFill = true; + N("복원 → " + bar.EnsureFillRenderable()); + + // ── ⑦ HP 0 숨김 ──────────────────────────────────────────────── + H("⑦ 보스 HP 0 → 숨김 예약"); + int zero0 = bar.ZeroHpHideCount; + BossHpBar.DebugFakeHp = 0f; + bar.PollBoss(true); + N("HP 0 → ratio=" + bar.LastRatio.ToString("F3") + " · ZeroHpHideCount " + zero0 + "→" + bar.ZeroHpHideCount); + Chk(bar.LastRatio <= 0.0001f, "HP 0 → ratio 0"); + Chk(bar.ZeroHpHideCount > zero0, "HP 0 숨김 예약 1회 (bossBarHideDelaySec " + hudS.bossBarHideDelaySec + "s 뒤)"); + boss.bossBarHideOnZeroHp = false; + int zero1 = bar.ZeroHpHideCount; + bar.PollBoss(true); + Chk(bar.ZeroHpHideCount == zero1, "C8: bossBarHideOnZeroHp=0 → 예약 생략"); + boss.bossBarHideOnZeroHp = sZero; + + // ── ⑧ 중복 노드 차단 ─────────────────────────────────────────── + H("⑧ 중복 노드 차단 — 같은 캔버스에 바를 하나 더 만들어 본다"); + dupGo = new GameObject(kBarName + "_DUP", typeof(RectTransform)); + dupGo.layer = barT.gameObject.layer; + dupGo.hideFlags = HideFlags.HideAndDontSave; + ((RectTransform)dupGo.transform).SetParent(hud, false); + var dup = dupGo.AddComponent(); + string dupInit = dup.Initialize(); + N("중복 Initialize → " + dupInit); + N("원본 BarInstanceCount=" + bar.BarInstanceCount + " · 중복 숨김=" + dup.DedupHidden + + " · 중복 Visible=" + dup.Visible); + Chk(dup.BarInstanceCount >= 2, "중복 감지 " + dup.BarInstanceCount + "벌"); + Chk(!dup.Visible, "중복 바는 숨김(alpha 0)"); + dup.Subscribe(false); + boss.bossBarDedupInstances = false; + N("C8: bossBarDedupInstances=0 → " + dup.DedupInstances()); + boss.bossBarDedupInstances = sDedup; + + // ── ⑨ GC ─────────────────────────────────────────────────────── + H("⑨ GC — 폴링 20,000 회 할당"); + BossHpBar.DebugFakeHp = 900f; + bar.PollBoss(true); + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long g0 = GC.GetTotalMemory(true); + for (int i = 0; i < 20000; i++) bar.PollBoss(true); + long g1 = GC.GetTotalMemory(false); + N("GC " + g0 + " → " + g1 + " (Δ " + (g1 - g0) + " B)"); + Chk(g1 - g0 <= 0, "폴링 20,000회 GC 증가 0 B (Δ " + (g1 - g0) + ")"); + + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long e0 = GC.GetTotalMemory(true); + for (int i = 0; i < 20000; i++) bar.EnsureFillRenderable(); + long e1 = GC.GetTotalMemory(false); + N("EnsureFillRenderable 20,000회: GC " + e0 + " → " + e1 + " (Δ " + (e1 - e0) + " B · 문자열 반환 포함)"); + + // ── ⑩ 수리 후 노드 전수 재덤프 ───────────────────────────────── + BossHpBar.DebugFakeHp = 869f; + bar.PollBoss(true); + DumpBarNodes(root, "⑩ 수리 후 노드 전수 (HP 869/1800)"); + N(bar.Dump()); + } + finally + { + try { if (dupGo != null) UnityEngine.Object.DestroyImmediate(dupGo); } catch { } + try { if (bar != null) bar.Subscribe(false); } catch { } + BossHpBar.ClearFakeHp(); + BossHpBar.DebugFallbackMaxHp = 100f; + BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; BossHpBar.RunResetCount = 0; + boss.bossBarForceFilledFill = sForce; boss.bossBarFillWhiteFallback = sWhite; + boss.bossBarSpriteOnBgAndTicks = sBgTick; boss.bossBarDedupInstances = sDedup; + boss.bossBarHideOnZeroHp = sZero; + try { if (root != null) PrefabUtility.UnloadPrefabContents(root); } catch { } + } + + // ── ⑪ 저장 0 · 구독 0 ─────────────────────────────────────────────── + H("⑪ 뒷정리 — 프리팹 저장 0 · 구독 0"); + var fi1 = new FileInfo(abs); fi1.Refresh(); + N("프리팹 후 = " + fi1.Length + " B · " + fi1.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff") + " UTC"); + Chk(fi1.Length == len0 && fi1.LastWriteTimeUtc == mt0, "프리팹 파일 무변동 = 저장 0"); + N("CombatEvents Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count + + " HitConfirmed=" + WL.Combat.Core.CombatEvents.HitConfirmed.Count + + " Killed=" + WL.Combat.Core.CombatEvents.Killed.Count + + " · RunStarted=" + WL.Combat.Run.RunEvents.RunStarted.Count); + N("SO 원복 = force:" + boss.bossBarForceFilledFill + " white:" + boss.bossBarFillWhiteFallback + + " bgTick:" + boss.bossBarSpriteOnBgAndTicks + " dedup:" + boss.bossBarDedupInstances + + " hp0:" + boss.bossBarHideOnZeroHp); + N("DebugFakeHp=" + BossHpBar.DebugFakeHp + " DebugFakeMaxHp=" + BossHpBar.DebugFakeMaxHp + + " DebugFallbackMaxHp=" + BossHpBar.DebugFallbackMaxHp); + + return Flush(); + } + + static string Flush() + { + _o.AppendLine(); + _o.AppendLine("== 합계 PASS " + _pass + " / FAIL " + _fail + " =="); + string p = OutPath("WL813c3_PROBE.txt"); + try { File.WriteAllText(p, _o.ToString(), new UTF8Encoding(true)); } catch { } + return _o.ToString() + "\n(파일: " + p + ")"; + } +} diff --git a/Assets/WL/UI/Scripts/BossHpBar.cs b/Assets/WL/UI/Scripts/BossHpBar.cs index 650b9875c..6a63ea0f1 100644 --- a/Assets/WL/UI/Scripts/BossHpBar.cs +++ b/Assets/WL/UI/Scripts/BossHpBar.cs @@ -86,10 +86,11 @@ namespace WL.UI 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 + " · " + late; + return "initialized subscribed=" + _subscribed + " · " + layout + " · " + dedup + " · " + late; } /// @@ -287,9 +288,22 @@ namespace WL.UI 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() { @@ -298,6 +312,14 @@ namespace WL.UI 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; } @@ -520,9 +542,117 @@ namespace WL.UI 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() { @@ -564,8 +694,10 @@ namespace WL.UI 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); + " ticks=" + (s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0) + " · " + render; } private void RebuildTicks(WLHudLayoutSettings s, float u) @@ -699,6 +831,24 @@ namespace WL.UI 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++) { diff --git a/Assets/WL/UI/Settings/Resources/WL/WLBossUiSettings.asset b/Assets/WL/UI/Settings/Resources/WL/WLBossUiSettings.asset index c2217474a..d56e7930b 100644 --- a/Assets/WL/UI/Settings/Resources/WL/WLBossUiSettings.asset +++ b/Assets/WL/UI/Settings/Resources/WL/WLBossUiSettings.asset @@ -35,4 +35,10 @@ MonoBehaviour: warnVignettePulses: 2 warnVignetteThicknessPx: 180 warnVignetteColor: {r: 1, g: 0.165, b: 0.165, a: 0.55} + bossBarForceFilledFill: 1 + bossBarFillSprite: {fileID: 0} + bossBarFillWhiteFallback: 1 + bossBarSpriteOnBgAndTicks: 0 + bossBarDedupInstances: 1 + bossBarHideOnZeroHp: 1 verboseLog: 0 diff --git a/Assets/WL/UI/Settings/WLBossUiSettings.cs b/Assets/WL/UI/Settings/WLBossUiSettings.cs index ea1503883..761c9599d 100644 --- a/Assets/WL/UI/Settings/WLBossUiSettings.cs +++ b/Assets/WL/UI/Settings/WLBossUiSettings.cs @@ -106,6 +106,31 @@ namespace WL.UI [Tooltip("비네트 색(알파 = 최고 밝기)")] public Color warnVignetteColor = new Color(1f, 0.165f, 0.165f, 0.55f); // #FF2A2A · a 0.55 + // ── 보스 HP 바 렌더 (WL-813c3 · Q7 결함 D-26 「계기는 맞고 화면은 틀리다」) ── + // 813c2 로 fillAmount 는 정확해졌는데(0.541/0.368/0.01) 화면 바는 만피였다. + // 실측 원인 = 바의 `Fill` Image 에 **스프라이트가 없다**(BossHpBar.cs:499 가 AddComponent 만 했다). + // uGUI `Image.OnPopulateMesh` 는 `activeSprite == null` 이면 `Graphic.OnPopulateMesh`(사각형 전체)로 + // 빠져 `type`/`fillAmount` 를 **아예 보지 않는다** → 계기 0.368 · 화면 100 %. + // 배치·색은 813c 의 WLHudLayoutSettings 가 그대로 갖는다. 여기 있는 것은 **렌더 설정**뿐이다. + [Header("보스 HP 바 렌더 (WL-813c3 · D-26)")] + [Tooltip("바가 켜질 때마다 fill 의 type=Filled · fillMethod=Horizontal · fillOrigin=Left 와 스프라이트를 강제한다. 끄면 813c2 상태(화면 만피)로 돌아간다 = C8 롤백")] + public bool bossBarForceFilledFill = true; + + [Tooltip("fill 이 쓸 스프라이트. 비워 두면 bossBarFillWhiteFallback 이 만든 흰 스프라이트를 쓴다(전용 아트가 생기면 여기에 물린다)")] + public Sprite bossBarFillSprite; + + [Tooltip("bossBarFillSprite 가 비었을 때 엔진 내장 흰 텍스처로 스프라이트를 **1개만** 만들어 공유한다(정적 캐시 · 프레임당 할당 0). 끄면 스프라이트를 안 물려 D-26 이 그대로 남는다")] + public bool bossBarFillWhiteFallback = true; + + [Tooltip("배경(Bg)·눈금(Tick_*)에도 같은 스프라이트를 물린다. 표시는 같고 머티리얼/배치만 통일된다(기본 꺼짐 = 원래 모습 유지)")] + public bool bossBarSpriteOnBgAndTicks = false; + + [Tooltip("같은 캔버스 아래에 BossHpBar 가 2개 이상이면 첫 1개만 남기고 나머지는 숨긴다(중복 노드가 뒤에서 만피로 보이는 것을 막는다)")] + public bool bossBarDedupInstances = true; + + [Tooltip("HP 가 0 이 되면 사망 판정 전이라도 바를 0 으로 내리고 숨김을 예약한다(WLHudLayoutSettings.bossBarHideDelaySec 뒤)")] + public bool bossBarHideOnZeroHp = true; + // ── 진단 ────────────────────────────────────────────────────────────── [Header("진단")] [Tooltip("배너·비네트 전이를 콘솔에 남긴다(Play 진단용 · 기본 꺼짐)")]