diff --git a/AgentScripts/WL813y_Apply.cs b/AgentScripts/WL813y_Apply.cs new file mode 100644 index 000000000..fdbd8d688 --- /dev/null +++ b/AgentScripts/WL813y_Apply.cs @@ -0,0 +1,117 @@ +// WL-813y 적용 — 설정 에셋 값 갱신(C8/C45 · 코드 상수 0). 프리팹은 건드리지 않는다. +// run_script --file AgentScripts/WL813y_Apply.cs --entry WL813y_Apply.Assets + +using System.Text; +using UnityEngine; +using UnityEditor; +using WL.UI; + +public static class WL813y_Apply +{ + public static string Assets() + { + var sb = new StringBuilder(); + + // ── ① 전투 패드 원점 복귀 + 하단 메뉴 숨김 (발주서 §1-6) ───────────── + var hud = Resources.Load(WLHudLayoutSettings.ResourcesPath); + if (hud == null) sb.AppendLine("WLHudLayoutSettings 없음"); + else + { + sb.AppendLine("[hud] 전 fanOrigin" + hud.fanOriginPx + " attackCenter" + hud.attackCenterPx); + hud.fanOriginPx = new Vector2(0f, 0f); // Q3 D-5: Lead 핫픽스 260 → 0 + hud.attackCenterPx = new Vector2(100f, 100f); // 360 → 100 + hud.idleFanOriginPx = new Vector2(0f, 260f); // 메뉴가 보이는 동안만 쓰던 자리 + hud.idleAttackCenterPx = new Vector2(100f, 360f); + hud.combatHideBottomMenu = true; + hud.bottomMenuPath = "IngameUIs/WL_HUD/HUD_BottomMenu"; // 813y 실측 경로 + hud.bottomMenuUseCanvasGroup = true; + hud.bottomMenuFadeSeconds = 0.18f; + hud.combatExitSeconds = 6f; + hud.combatEnterDelaySeconds = 0f; + hud.combatUseTargetAsSignal = true; + hud.bottomMenuRestoreButton = true; + hud.restoreButtonCenterPx = new Vector2(-380f, 62f); + hud.restoreButtonDiameterPx = 88f; + hud.restoreButtonLabel = "≡"; + hud.restoreButtonFontPx = 46f; + hud.restoreHoldSeconds = 8f; + hud.bossBarBindExisting = true; // Q3 D-3 + EditorUtility.SetDirty(hud); + sb.AppendLine("[hud] 후 fanOrigin" + hud.fanOriginPx + " attackCenter" + hud.attackCenterPx + + " idleFanOrigin" + hud.idleFanOriginPx + " idleAttack" + hud.idleAttackCenterPx + + " menu=" + hud.bottomMenuPath + " exit=" + hud.combatExitSeconds + "s"); + } + + // ── ② 토스트 가시성 + 텍스트 아웃라인 (발주서 §1-3 · §1-4) ─────────── + var txt = Resources.Load(WLCombatTextSettings.ResourcesPath); + if (txt == null) sb.AppendLine("WLCombatTextSettings 없음"); + else + { + sb.AppendLine("[text] 전 toast(sec=" + txt.lootToastSeconds + " font=" + txt.lootToastFontPx + + " w=" + txt.lootToastWidthPx + " lh=" + txt.lootToastLineHeightPx + + " centerOff=" + txt.lootToastCenterOffsetPx + ")"); + txt.lootToastSeconds = 2.0f; // 발주서 §1-3: 1.2 → 2.0 s + txt.lootToastFontPx = 52f; // Q3 D-6 ②: 40 px 는 세로 캡처에서 육안 실패 + txt.lootToastLineHeightPx = 68f; // 폰트 + 패널 여백이 들어갈 줄 간격 + txt.lootToastWidthPx = 560f; + txt.lootToastCenterOffsetPx = -120f; // 스탯 팝업(+180) · 연쇄 문구(+520) 대역을 피한다 + txt.lootToastRightMarginPx = 40f; + txt.lootToastPanelEnabled = true; + txt.lootToastPanelColor = new Color(0.03f, 0.03f, 0.05f, 0.68f); + txt.lootToastPanelPadXPx = 18f; + txt.lootToastPanelPadYPx = 6f; + txt.lootToastMinBrightness = 0.72f; // 등급 1 회색(#a6a6a7 · V 0.65)을 끌어올린다 + txt.lootToastMinBrightColor = new Color(0.95f, 0.96f, 0.98f, 1f); + + txt.textEdgeEnabled = true; + txt.killChainEdge = new WLTextEdge + { + enabled = true, outlineWidth = 0.30f, outlineColor = new Color(0.05f, 0.03f, 0.02f, 1f), + shadowOffset = new Vector2(0.55f, -0.55f), shadowColor = new Color(0f, 0f, 0f, 0.8f), + shadowSoftness = 0.25f, faceDilate = 0.1f, + }; + txt.statPopupEdge = new WLTextEdge + { + enabled = true, outlineWidth = 0.26f, outlineColor = new Color(0.05f, 0.03f, 0.02f, 1f), + shadowOffset = new Vector2(0.45f, -0.45f), shadowColor = new Color(0f, 0f, 0f, 0.75f), + shadowSoftness = 0.2f, faceDilate = 0.08f, + }; + txt.damageEdge = new WLTextEdge + { + enabled = true, outlineWidth = 0.22f, outlineColor = new Color(0.03f, 0.02f, 0.02f, 1f), + shadowOffset = new Vector2(0.35f, -0.35f), shadowColor = new Color(0f, 0f, 0f, 0.7f), + shadowSoftness = 0.15f, faceDilate = 0.05f, + }; + txt.lootToastEdge = new WLTextEdge + { + enabled = true, outlineWidth = 0.24f, outlineColor = new Color(0.03f, 0.02f, 0.02f, 1f), + shadowOffset = new Vector2(0.4f, -0.4f), shadowColor = new Color(0f, 0f, 0f, 0.7f), + shadowSoftness = 0.18f, faceDilate = 0.06f, + }; + + txt.statPopupUseGrowthEvents = true; // 발주서 §1-2 · 813k 축 + txt.statPopupPointFormat = "+5 포인트 (보유 {0})"; + txt.statPopupMergeMultiLevel = true; + EditorUtility.SetDirty(txt); + sb.AppendLine("[text] 후 toast(sec=" + txt.lootToastSeconds + " font=" + txt.lootToastFontPx + + " w=" + txt.lootToastWidthPx + " lh=" + txt.lootToastLineHeightPx + + " centerOff=" + txt.lootToastCenterOffsetPx + " panel=" + txt.lootToastPanelEnabled + ")"); + sb.AppendLine("[text] edge kill=" + txt.killChainEdge + " / stat=" + txt.statPopupEdge + + " / dmg=" + txt.damageEdge + " / loot=" + txt.lootToastEdge); + } + + // ── ③ 보스 배너 늦은 구독 (발주서 §1-5) ────────────────────────────── + var boss = Resources.Load(WLBossUiSettings.ResourcesPath); + if (boss == null) sb.AppendLine("WLBossUiSettings 없음"); + else + { + boss.bannerTriggerExisting = true; + EditorUtility.SetDirty(boss); + sb.AppendLine("[boss] bannerTriggerExisting=" + boss.bannerTriggerExisting); + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + return sb.ToString(); + } +} diff --git a/AgentScripts/WL813y_Probe.cs b/AgentScripts/WL813y_Probe.cs new file mode 100644 index 000000000..d96598422 --- /dev/null +++ b/AgentScripts/WL813y_Probe.cs @@ -0,0 +1,636 @@ +// WL-813y 프로브 — UI 핫픽스 묶음 실측(에디트 모드 · Play 0). +// run_script --file AgentScripts/WL813y_Probe.cs --entry WL813y_Probe. +// Nodes · Bridge · Stat · Text · BossLate · Hud +// 로그는 /Logs/WL813y_*.txt (Assets 밖 · 미추적). +// 🔴 프리팹은 LoadPrefabContents 로 열고 **저장하지 않는다**. 구독·임시 노드는 전부 되돌린다. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using UnityEditor; +using WL.UI; + +public static class WL813y_Probe +{ + const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; + const string kHud = "IngameUIs/WL_HUD"; + const string kPad = "IngameUIs/BattleUI"; + const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic; + + static string LogDir + { + get + { + string d = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Logs"); + if (!Directory.Exists(d)) Directory.CreateDirectory(d); + return d; + } + } + + static string Write(string name, string body) + { + string p = Path.Combine(LogDir, name); + File.WriteAllText(p, body, new UTF8Encoding(false)); + return p; + } + + static Transform Find(Transform root, string path) + { + var cur = root; + foreach (var part in path.Split('/')) + { + Transform next = null; + for (int i = 0; i < cur.childCount; i++) + if (cur.GetChild(i).name == part) { next = cur.GetChild(i); break; } + if (next == null) return null; + cur = next; + } + return cur; + } + + static string PathOf(Transform t, Transform root) + { + var sb = new StringBuilder(t.name); + var c = t.parent; + while (c != null && c != root) { sb.Insert(0, c.name + "/"); c = c.parent; } + return sb.ToString(); + } + + // ── ① 프리팹 노드 탐색 — HUD_BottomMenu 경로 · WL_HUD 자식 · 패드 좌표 ────── + public static string Nodes() + { + var sb = new StringBuilder(); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + try + { + var rt = root.transform; + sb.AppendLine("[1] 루트 자식 " + rt.childCount + "개"); + foreach (Transform c in rt) sb.AppendLine(" - " + c.name + " active=" + c.gameObject.activeSelf); + + sb.AppendLine(); + sb.AppendLine("[2] 이름에 'Bottom' / 'Menu' 가 든 노드 (전수 탐색)"); + var all = root.GetComponentsInChildren(true); + sb.AppendLine(" 총 Transform = " + all.Length); + foreach (var t in all) + { + if (t.name.IndexOf("Bottom", StringComparison.OrdinalIgnoreCase) < 0 && + t.name.IndexOf("Menu", StringComparison.OrdinalIgnoreCase) < 0) continue; + var r = t as RectTransform; + sb.AppendLine(" - " + PathOf(t, rt) + " active=" + t.gameObject.activeSelf + + " children=" + t.childCount + + (r != null ? " pos" + r.anchoredPosition + " size" + r.sizeDelta + + " aMin" + r.anchorMin + " aMax" + r.anchorMax + " pivot" + r.pivot : "")); + foreach (Transform cc in t) sb.AppendLine(" · " + cc.name + " active=" + cc.gameObject.activeSelf); + } + + sb.AppendLine(); + sb.AppendLine("[3] IngameUIs 아래 1단계"); + var ing = WLVignetteUtil.FindUiPath(rt, "IngameUIs"); + if (ing == null) sb.AppendLine(" IngameUIs 없음"); + else foreach (Transform c in ing) sb.AppendLine(" - " + c.name + " active=" + c.gameObject.activeSelf + " children=" + c.childCount); + + sb.AppendLine(); + sb.AppendLine("[4] WL_HUD 자식"); + var hud = WLVignetteUtil.FindUiPath(rt, kHud); + if (hud == null) sb.AppendLine(" WL_HUD 없음"); + else + { + sb.AppendLine(" WL_HUD SafeAreaFitter=" + (hud.GetComponent() != null)); + foreach (Transform c in hud) + { + var comps = c.GetComponents(); + var names = new List(); + foreach (var cp in comps) names.Add(cp == null ? "(missing)" : cp.GetType().Name); + sb.AppendLine(" - " + c.name + " [" + string.Join(", ", names.ToArray()) + "] children=" + c.childCount); + } + } + + sb.AppendLine(); + sb.AppendLine("[5] BattleUI 자식"); + var bui = WLVignetteUtil.FindUiPath(rt, kPad); + if (bui == null) sb.AppendLine(" BattleUI 없음"); + else foreach (Transform c in bui) + { + var r = c as RectTransform; + sb.AppendLine(" - " + c.name + " active=" + c.gameObject.activeSelf + + (r != null ? " pos" + r.anchoredPosition + " size" + r.sizeDelta + " aMin" + r.anchorMin : "")); + } + } + finally { PrefabUtility.UnloadPrefabContents(root); } + + string p = Write("WL813y_nodes.txt", sb.ToString()); + return sb.ToString() + "\n→ " + p; + } + + // ═══ ⓑ ① 813i 연결 6줄 (발주서 §1-1) ═══════════════════════════════════ + public static string Bridge() + { + var sb = new StringBuilder(); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + int ov0 = WL.Combat.Survival.WLSurvivalSettings.RuntimeOverride; + float ts0 = Time.timeScale; + try + { + var hud = Find(root.transform, kHud); + var pad = Find(root.transform, kPad); + var dlgT = hud != null ? hud.Find("WL_ReviveDialog") : null; + var dlg = dlgT != null ? dlgT.GetComponent() : null; + var potion = pad != null ? pad.GetComponentInChildren(true) : null; + if (dlg == null) return "🔴 WL_ReviveDialog 없음"; + if (potion == null) return "🔴 WL_PotionButton 없음"; + dlg.Initialize(); + potion.Initialize(); + + sb.AppendLine("[0] 연결 전 (813tj 상태)"); + WLSurvivalUiBridge.Uninstall(); + WLSurvivalUiBridge.ResetDiagnostics(); + sb.AppendLine(" " + WLSurvivalUiBridge.Dump().TrimEnd()); + + sb.AppendLine(); + sb.AppendLine("[1] Install() — 6줄 연결"); + sb.AppendLine(" " + WLSurvivalUiBridge.Install()); + sb.Append(WLSurvivalUiBridge.Dump()); + + sb.AppendLine(); + sb.AppendLine("[2] 물약 4줄 — 스위치 off(에셋 enabled=false · 병합 전) → 813tj 표시용 폴백"); + WL.Combat.Survival.WLSurvivalSettings.RuntimeOverride = -1; + sb.AppendLine(" survivalEnabled=" + WL.Combat.Survival.WLSurvivalSettings.Enabled + + " · UI 가 읽는 count=" + PotionButton.CountProvider() + + " cdRemain=" + PotionButton.CooldownRemainProvider().ToString("F2") + + " cdTotal=" + PotionButton.CooldownTotalProvider().ToString("F2") + + " · 버튼 ReadCount=" + potion.ReadCount() + " fill=" + potion.ReadCooldownFill().ToString("F3")); + sb.AppendLine(" 클릭 → " + potion.OnClickPotion()); + + sb.AppendLine(); + sb.AppendLine("[3] 물약 4줄 — 스위치 on(RuntimeOverride=1 · Lead 가 병합 시 켜는 상태) → 813i PotionUse"); + WL.Combat.Survival.WLSurvivalSettings.RuntimeOverride = 1; + WL.Combat.Survival.PotionUse.ResetRun("probe"); + WL.Combat.Survival.PotionUse.ResetDiagnostics(); + sb.AppendLine(" survivalEnabled=" + WL.Combat.Survival.WLSurvivalSettings.Enabled + + " · 813i Remaining=" + WL.Combat.Survival.PotionUse.Remaining + "/" + WL.Combat.Survival.PotionUse.Max + + " cdTotal=" + WL.Combat.Survival.PotionUse.CooldownTotal.ToString("F2") + "s"); + sb.AppendLine(" UI 가 읽는 count=" + PotionButton.CountProvider() + + " cdRemain=" + PotionButton.CooldownRemainProvider().ToString("F2") + + " cdTotal=" + PotionButton.CooldownTotalProvider().ToString("F2") + + " ← ① ② ③ 이 813i 값을 그대로 읽는다"); + int chg0 = WLSurvivalUiBridge.PotionChangedSeen; + sb.AppendLine(" ④ UseHandler() → " + PotionButton.UseHandler() + + " (PC 없는 에디트 모드라 813i 가 " + WL.Combat.Survival.PotionUse.LastResult + + " 로 거절 · 호출은 도달) · Changed 수신 " + chg0 + "→" + WLSurvivalUiBridge.PotionChangedSeen); + sb.AppendLine(" 버튼 클릭 경로 → " + potion.OnClickPotion()); + + sb.AppendLine(); + sb.AppendLine("[4] 부활 2줄 — 813i DeathFlow 왕복(가짜 메인 PC · ReviveOverride 로 원본 회복 경로 차단)"); + var deathFlowOverride = WL.Combat.Survival.DeathFlow.ReviveOverride; + int overrideCalls = 0; + WL.Combat.Survival.DeathFlow.ReviveOverride = (p, r) => { overrideCalls++; }; + var pc = MakePC(); + try + { + WL.Combat.Survival.DeathFlow.ResetDiagnostics(); + WLSurvivalUiBridge.ResetDiagnostics(); + dlg.HideNow(); + float t = 100f; + WL.Combat.Survival.DeathFlow.Tick(t); + sb.AppendLine(" ⑤ OnPCDied → " + "state=" + WL.Combat.Survival.DeathFlow.State); + WL.Combat.Survival.DeathFlow.OnPCDied(pc); + sb.AppendLine(" Died 수신=" + WLSurvivalUiBridge.DiedSeen + " state=" + WL.Combat.Survival.DeathFlow.State + + " · 팝업 표시=" + dlg.FallbackVisible + "(사망 연출 대기 · 예약=" + dlg.Pending + ")"); + var st = WL.Combat.Survival.WLSurvivalSettings.Instance; + float disp = st != null ? st.deathDisplaySeconds : 2f; + WL.Combat.Survival.DeathFlow.Tick(t + disp + 0.01f); + sb.AppendLine(" +" + disp.ToString("F2") + "s → ReviveRequested 수신=" + WLSurvivalUiBridge.ReviveRequestedSeen + + " state=" + WL.Combat.Survival.DeathFlow.State + + " · 팝업 표시=" + dlg.FallbackVisible + " shows=" + dlg.ShownCount + + " 본문=\"" + dlg.LastMessage.Replace("\n", " / ") + "\""); + sb.AppendLine(" autoReviveWhenNoListener 무력화 = ReviveRequested 구독자 " + + WL.Combat.Survival.DeathFlow.ReviveRequested.Count + "(0 이면 813i 가 자동 부활한다)"); + sb.AppendLine(" ⑥ 팝업 확인 버튼 → " + dlg.OnConfirm()); + sb.AppendLine(" DeathFlow.Revive() 호출=" + WLSurvivalUiBridge.ReviveCalls + + " state=" + WL.Combat.Survival.DeathFlow.State + + " reviveCount=" + WL.Combat.Survival.DeathFlow.ReviveCount + + " (ReviveOverride 호출 " + overrideCalls + ")"); + sb.AppendLine(" Revived 수신=" + WLSurvivalUiBridge.RevivedSeen + " → 팝업 닫힘=" + !dlg.FallbackVisible); + sb.AppendLine(" 최종 " + WLSurvivalUiBridge.Dump().Split('\n')[0].Trim()); + } + finally + { + WL.Combat.Survival.DeathFlow.ReviveOverride = deathFlowOverride; + if (pc != null) UnityEngine.Object.DestroyImmediate(pc.gameObject); + } + } + finally + { + WLSurvivalUiBridge.Uninstall(); + WL.Combat.Survival.WLSurvivalSettings.RuntimeOverride = ov0; + WL.Combat.Survival.DeathFlow.ResetDiagnostics(); + Time.timeScale = ts0; + KillRunners(); + PrefabUtility.UnloadPrefabContents(root); + } + sb.AppendLine(); + sb.AppendLine("정리: 브리지 해제=" + !WLSurvivalUiBridge.Installed + + " · DeathFlow 구독(Died/Req/Revived)=" + WL.Combat.Survival.DeathFlow.Died.Count + "/" + + WL.Combat.Survival.DeathFlow.ReviveRequested.Count + "/" + WL.Combat.Survival.DeathFlow.Revived.Count + + " · PotionButton.HasProvider=" + PotionButton.HasProvider + + " · timeScale=" + Time.timeScale + " · SurvivalRunner=" + WL.Combat.Survival.SurvivalRunner.Exists); + var body = sb.ToString(); + return body + "\n→ " + Write("WL813y_bridge.txt", body); + } + + static PCActor MakePC() + { + var go = new GameObject("__WL813y_PC"); + var pc = go.AddComponent(); + pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None; + var stat = new ActorStatInfo(eRole.PC); + stat.Set_Stat(eStat.MaxHP, 1000d); stat.Set_Stat(eStat.HP, 0d); + typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat); + typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false); + typeof(Actor).GetField("DeadStatus", NP).SetValue(pc, true); + return pc; + } + + static void KillRunners() + { + var all = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + foreach (var m in all) + { + if (m == null) continue; + string n = m.GetType().Name; + if (n == "SurvivalRunner" || n == "HudCombatVisibilityRunner") + UnityEngine.Object.DestroyImmediate(m.gameObject); + } + var leftovers = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + foreach (var a in leftovers) + if (a != null && a.name.StartsWith("__WL813y_")) UnityEngine.Object.DestroyImmediate(a.gameObject); + } + + // ═══ ⓑ ② 스탯 팝업 3줄 (발주서 §1-2) ═══════════════════════════════════ + public static string Stat() + { + var sb = new StringBuilder(); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + StatPopup sp = null; + try + { + var hud = Find(root.transform, kHud); + var node = hud != null ? hud.Find("WL_StatPopup") : null; + sp = node != null ? node.GetComponent() : null; + if (sp == null) return "🔴 WL_StatPopup 없음"; + var s = WLCombatTextSettings.Instance; + if (s == null) return "🔴 WLCombatTextSettings 에셋 없음"; + + sb.AppendLine("[0] 구독 축 선택 (statPopupUseGrowthEvents=" + s.statPopupUseGrowthEvents + ")"); + sb.AppendLine(" 구독 전 growth=" + WL.Combat.Growth.GrowthEvents.LevelUp.Count + + " core=" + WL.Combat.Core.CombatEvents.LevelUp.Count); + sb.AppendLine(" " + sp.Initialize()); + sb.AppendLine(" 구독 후 growth=" + WL.Combat.Growth.GrowthEvents.LevelUp.Count + + " core=" + WL.Combat.Core.CombatEvents.LevelUp.Count + + " · UsingGrowthEvents=" + sp.UsingGrowthEvents + + " ← 813k 축 하나만 문다(이중 팝업 방지)"); + + sb.AppendLine(); + sb.AppendLine("[1] 813k 실제 Dispatch — 스탯이 오른 경우(statDelta=+12) → 3줄"); + sb.AppendLine(" " + StatPopup.RaiseFakeGrowthLevelUp(7, 30, 12d)); + sb.AppendLine(" rows=" + sp.LastRowCount + " 포인트줄=" + sp.UsedPointLine + + " visible=" + sp.Visible + " level=" + sp.LastLevel); + sb.AppendLine(" text = " + sp.LastText); + + sb.AppendLine(); + sb.AppendLine("[2] 813k 실제 Dispatch — 스탯 변화 0(원본 기본값 · 813k 보고 ①) → \"포인트\" 줄"); + sb.AppendLine(" " + StatPopup.RaiseFakeGrowthLevelUp(8, 35, 0d)); + sb.AppendLine(" rows=" + sp.LastRowCount + " 포인트줄=" + sp.UsedPointLine + + " statPointTotal=" + sp.LastStatPointTotal + " levelsGained=" + sp.LastLevelsGained); + sb.AppendLine(" text = " + sp.LastText); + sb.AppendLine(" 포맷 = \"" + s.statPopupPointFormat + "\" (SO · C8)"); + + sb.AppendLine(); + sb.AppendLine("[3] 811b 코어 축은 이제 안 문다 — CombatEvents.LevelUp 을 쏴도 무반응(이중 팝업 0)"); + int before = sp.LevelUpSeen; + sb.AppendLine(" " + StatPopup.RaiseFakeLevelUp(9)); + sb.AppendLine(" levelUpSeen " + before + "→" + sp.LevelUpSeen + " lastLevel=" + sp.LastLevel + "(8 이어야 한다)"); + + sb.AppendLine(); + sb.AppendLine("[4] 최종 덤프"); + sb.Append(sp.Dump()); + } + finally + { + if (sp != null) sp.Subscribe(false); + PrefabUtility.UnloadPrefabContents(root); + } + sb.AppendLine(); + sb.AppendLine("해제 후 구독자 growth=" + WL.Combat.Growth.GrowthEvents.LevelUp.Count + + " core=" + WL.Combat.Core.CombatEvents.LevelUp.Count + " (0/0 이어야 한다)"); + var body = sb.ToString(); + return body + "\n→ " + Write("WL813y_stat.txt", body); + } + + // ═══ ⓑ ③④ 토스트 가시성 · 텍스트 아웃라인 (발주서 §1-3 · §1-4) ═════════ + public static string Text() + { + var sb = new StringBuilder(); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + LootToast lt = null; KillChainText kc = null; StatPopup sp = null; + try + { + var hud = Find(root.transform, kHud); + var s = WLCombatTextSettings.Instance; + if (s == null) return "🔴 WLCombatTextSettings 에셋 없음"; + lt = hud.Find("WL_LootToast").GetComponent(); + kc = hud.Find("WL_KillChainText").GetComponent(); + sp = hud.Find("WL_StatPopup").GetComponent(); + + float u = WLVignetteUtil.UnitsPerPx(hud.GetComponentInParent()); + sb.AppendLine("[0] 기준 — 설계 " + s.designWidthPx + "×" + WLHudLayoutSettings.Instance.designHeightPx + + " · unitsPerPx=" + u.ToString("F4") + " · textEdgeEnabled=" + s.textEdgeEnabled); + + sb.AppendLine(); + sb.AppendLine("[1] 토스트 배치 (Q3 결함 D-6 ② · 15회 발생 · 육안 실패)"); + sb.AppendLine(" 지속 " + s.lootToastSeconds + "s (발주서 1.2 → 2.0) · 글자 " + s.lootToastFontPx + + "px · 줄높이 " + s.lootToastLineHeightPx + "px · 폭 " + s.lootToastWidthPx + + "px · 우측여백 " + s.lootToastRightMarginPx + "px · 중심오프셋 " + s.lootToastCenterOffsetPx + "px"); + sb.AppendLine(" " + lt.Initialize()); + sb.AppendLine(" " + lt.ApplyLayout()); + var ltRt = lt.GetComponent(); + sb.AppendLine(" 루트 pos" + ltRt.anchoredPosition + " size" + ltRt.sizeDelta + + " aMin" + ltRt.anchorMin + " aMax" + ltRt.anchorMax + + " · SafeArea 안(부모 WL_HUD SafeAreaFitter=" + + (hud.GetComponent() != null) + ")"); + sb.AppendLine(" 등급 색 보정: 등급1 " + s.GradeColorTag(1) + " → " + s.GradeColorTagBright(1) + + " · 등급2 " + s.GradeColorTag(2) + " → " + s.GradeColorTagBright(2) + + " (밝기하한 " + s.lootToastMinBrightness + ")"); + sb.AppendLine(" " + lt.Push(1, 3)); + sb.AppendLine(" " + lt.Push(2, 1)); + sb.Append(lt.Dump()); + + sb.AppendLine(); + sb.AppendLine("[2] 아웃라인 · 그림자 — TMP 머티리얼 실측값(uGUI Outline/Shadow 는 TMP 에 안 먹는다)"); + sb.AppendLine(" [연쇄] 값 " + s.Edge(s.killChainEdge)); + sb.AppendLine(" " + kc.Initialize()); + sb.AppendLine(" 실측 " + kc.LastEdge); + sb.AppendLine(" [스탯] 값 " + s.Edge(s.statPopupEdge)); + sb.AppendLine(" " + sp.Initialize()); + sb.AppendLine(" 실측 " + sp.LastEdge); + sb.AppendLine(" [토스트] 값 " + s.Edge(s.lootToastEdge)); + sb.AppendLine(" 실측 " + lt.LastEdge); + sb.AppendLine(" [데미지] 값 " + s.Edge(s.damageEdge)); + sb.AppendLine(" 풀 인스턴스 없음: " + HUDDMGUI.RefreshEdges()); + // 데미지 숫자는 InGameInfo.cs:110 이 Addressables 로 Res_Addr/Ingame/HUDDMGUI.prefab 을 12개 복제한다 + // (Systems 소유 · **읽기만** — 프로브에서 1개만 인스턴스화해 머티리얼 경로를 확인하고 즉시 파기한다). + var dmgSrc = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/Ingame/HUDDMGUI.prefab"); + if (dmgSrc == null) sb.AppendLine(" 🔴 HUDDMGUI.prefab 없음"); + else + { + var inst = (GameObject)PrefabUtility.InstantiatePrefab(dmgSrc); + try + { + var d = inst.GetComponent(); + sb.AppendLine(" prefab 인스턴스 1개(dmgs=" + (d != null && d.dmgs != null ? d.dmgs.Length : 0) + "라벨) → " + HUDDMGUI.RefreshEdges()); + if (d != null && d.dmgs != null) + for (int i = 0; i < d.dmgs.Length; i++) + if (d.dmgs[i] != null) + sb.AppendLine(" dmgs[" + i + "] mat=" + d.dmgs[i].fontSharedMaterial.name + + " outlineW=" + d.dmgs[i].fontSharedMaterial.GetFloat("_OutlineWidth").ToString("F3") + + " UNDERLAY_ON=" + d.dmgs[i].fontSharedMaterial.IsKeywordEnabled("UNDERLAY_ON")); + } + finally { UnityEngine.Object.DestroyImmediate(inst); } + sb.AppendLine(" 🔴 HUDDMGUI.prefab 은 **읽기 전용** — 인스턴스만 만들고 파기(에셋 저장 0 · Systems 소유)"); + } + + sb.AppendLine(); + sb.AppendLine("[3] 배칭 — 변형 머티리얼은 (원본 머티리얼 + 값) 조합마다 1장 공유(라벨별 인스턴스 0)"); + var mats = new Dictionary(); + var matName = new Dictionary(); + foreach (var t in root.GetComponentsInChildren(true)) + { + var m = t.fontSharedMaterial; + if (m == null || m.name.IndexOf("WL813y") < 0) continue; + int id = m.GetInstanceID(); + int n; mats.TryGetValue(id, out n); mats[id] = n + 1; + matName[id] = m.name + " outlineW=" + m.GetFloat("_OutlineWidth").ToString("F3") + + " underlayOff(" + m.GetFloat("_UnderlayOffsetX").ToString("F2") + ")"; + } + if (mats.Count == 0) sb.AppendLine(" 변형 머티리얼 0 (edge off)"); + sb.AppendLine(" 서로 다른 머티리얼 " + mats.Count + "장 (값 조합 3종 = 연쇄 0.30 / 스탯 0.26 / 토스트 0.24)"); + foreach (var kv in mats) sb.AppendLine(" · " + matName[kv.Key] + " ← 라벨 " + kv.Value + "개 공유"); + } + finally + { + if (lt != null) lt.Subscribe(false); + if (kc != null) kc.Subscribe(false); + if (sp != null) sp.Subscribe(false); + PrefabUtility.UnloadPrefabContents(root); + } + var body = sb.ToString(); + return body + "\n→ " + Write("WL813y_text.txt", body); + } + + // ═══ ⓑ ⑤ 보스 HP 바 · 배너 늦은 구독 보정 (발주서 §1-5) ════════════════ + public static string BossLate() + { + var sb = new StringBuilder(); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + BossHpBar bar = null; BossBanner ban = null; MobActor boss = null; + try + { + var hud = Find(root.transform, kHud); + bar = hud.Find("WL_BossHpBar").GetComponent(); + ban = hud.Find("WL_BossBanner").GetComponent(); + + sb.AppendLine("[0] Q3 결함 D-3 재현 — 보스가 **먼저** 스폰되고 HUD 가 **나중에** 켜진다"); + sb.AppendLine(" (Q3 실측: SpawnedSeen=232 · _bound=False — Spawned(isBoss) 가 구독 전에 지나갔다)"); + boss = MakeBoss(); + sb.AppendLine(" 가짜 보스 = " + boss.name + " m_SubRole=" + boss.m_SubRole + + " IsSubRole(Boss)=" + boss.IsSubRole(eSubRol.Boss) + " IsDead=" + boss.IsDead() + + " activeInHierarchy=" + boss.gameObject.activeInHierarchy); + + sb.AppendLine(); + sb.AppendLine("[1] 경로 ① BossArena.Boss (813d 게이트가 잡아 둔 보스)"); + WL.Combat.Boss.BossArena.SetBossForProbe(boss); + sb.AppendLine(" BossArena.Boss=" + (WL.Combat.Boss.BossArena.Boss != null ? WL.Combat.Boss.BossArena.Boss.name : "없음")); + sb.AppendLine(" 보스바 " + bar.Initialize()); + sb.AppendLine(" → visible=" + bar.Visible + " ratio=" + bar.LastRatio.ToString("F3") + + " 늦은구독=" + bar.LateBound + " (기대 True)"); + sb.AppendLine(" 배너 " + ban.Initialize()); + ban.Tick(Time.unscaledTime + 0.2f); + sb.AppendLine(" → visible=" + ban.Visible + " shows=" + ban.ShowCount + + " 늦은구독=" + ban.LateTriggered + " α=" + ban.BannerAlpha.ToString("F3") + " (기대 True)"); + + sb.AppendLine(); + sb.AppendLine("[2] 경로 ② 씬 1회 스캔 (게이트 이전 = 맵 로드 시 스폰 · BossArena 비어 있음)"); + bar.Subscribe(false); ban.Subscribe(false); + WL.Combat.Boss.BossArena.SetBossForProbe(null); + var bar2 = hud.Find("WL_BossHpBar").GetComponent(); + typeof(BossHpBar).GetField("_bound", NP).SetValue(bar2, false); + sb.AppendLine(" BossArena.Boss=없음 · " + bar2.BindExistingBoss()); + sb.AppendLine(" → visible=" + bar2.Visible + " 늦은구독=" + bar2.LateBound + " 스캔=" + bar2.LateScanCount); + sb.AppendLine(" 배너 " + ban.TriggerExistingBoss()); + + sb.AppendLine(); + sb.AppendLine("[3] 살아 있는 보스가 없으면 아무 일도 없어야 한다(오탐 0)"); + boss.gameObject.SetActive(false); + var bar3 = hud.Find("WL_BossHpBar").GetComponent(); + typeof(BossHpBar).GetField("_bound", NP).SetValue(bar3, false); + sb.AppendLine(" 보스 비활성 → " + bar3.BindExistingBoss()); + boss.gameObject.SetActive(true); + + sb.AppendLine(); + sb.AppendLine("[4] C8 롤백 — bossBarBindExisting / bannerTriggerExisting = false 면 생략"); + var hs = WLHudLayoutSettings.Instance; var bs = WLBossUiSettings.Instance; + bool h0 = hs.bossBarBindExisting, b0 = bs.bannerTriggerExisting; + hs.bossBarBindExisting = false; bs.bannerTriggerExisting = false; + var bar4 = hud.Find("WL_BossHpBar").GetComponent(); + typeof(BossHpBar).GetField("_bound", NP).SetValue(bar4, false); + sb.AppendLine(" " + bar4.BindExistingBoss()); + sb.AppendLine(" " + ban.TriggerExistingBoss()); + hs.bossBarBindExisting = h0; bs.bannerTriggerExisting = b0; + + sb.AppendLine(); + sb.AppendLine("[5] 최종 덤프"); + sb.Append(bar.Dump()); + sb.Append(ban.Dump()); + } + finally + { + if (bar != null) bar.Subscribe(false); + if (ban != null) ban.Subscribe(false); + WL.Combat.Boss.BossArena.SetBossForProbe(null); + if (boss != null) UnityEngine.Object.DestroyImmediate(boss.gameObject); + PrefabUtility.UnloadPrefabContents(root); + } + sb.AppendLine(); + sb.AppendLine("해제 후 구독자 Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count + + " BossPhase=" + WL.Combat.Boss.BossEvents.BossPhase.Count + " (0/0 이어야 한다)" + + " · BossArena.Boss=" + (WL.Combat.Boss.BossArena.Boss != null ? "남음🔴" : "없음")); + var body = sb.ToString(); + return body + "\n→ " + Write("WL813y_bosslate.txt", body); + } + + static string MenuBlocks(Transform root, WLHudLayoutSettings s) + { + var m = WLVignetteUtil.FindUiPath(root, s.bottomMenuPath); + if (m == null) return "메뉴 없음"; + var cg = m.GetComponent(); + return cg == null ? "CanvasGroup 없음🔴" : cg.blocksRaycasts.ToString(); + } + + static MobActor MakeBoss() + { + var go = new GameObject("__WL813y_Boss"); + var m = go.AddComponent(); + m.m_Role = eRole.Mob; m.m_SubRole = eSubRol.Boss; + var stat = new ActorStatInfo(eRole.Mob); + stat.Set_Stat(eStat.MaxHP, 50000d); stat.Set_Stat(eStat.HP, 50000d); + typeof(Actor).GetField("m_Stat", NP).SetValue(m, stat); + typeof(Actor).GetField("m_Enemy", NP).SetValue(m, true); + return m; + } + + // ═══ ⓑ ⑥ 전투 중 하단 메뉴 숨김 + 패드 원점 복귀 (발주서 §1-6) ══════════ + public static string Hud() + { + var sb = new StringBuilder(); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + try + { + var s = WLHudLayoutSettings.Instance; + if (s == null) return "🔴 WLHudLayoutSettings 에셋 없음"; + var padT = Find(root.transform, kPad); + var pad = padT != null ? padT.GetComponent() : null; + if (pad == null) pad = root.GetComponentInChildren(true); + if (pad == null) return "🔴 WLBattlePadLayout 없음"; + + sb.AppendLine("[0] 에셋 값 (C8/C45 · 코드 상수 0)"); + sb.AppendLine(" 전투(메뉴 숨김) fanOrigin=" + s.fanOriginPx + " attackCenter=" + s.attackCenterPx); + sb.AppendLine(" idle(메뉴 보임) fanOrigin=" + s.idleFanOriginPx + " attackCenter=" + s.idleAttackCenterPx); + sb.AppendLine(" fanRadius=" + s.fanRadiusPx + " slot⌀=" + s.slotDiameterPx + + " reserveRadius=" + s.reserveRadiusPx + " reserve⌀=" + s.reserveDiameterPx + + " attack⌀=" + s.attackDiameterPx + " 목표반경=" + s.thumbReachRadiusPx); + sb.AppendLine(" combatHideBottomMenu=" + s.combatHideBottomMenu + " path=\"" + s.bottomMenuPath + + "\" exit=" + s.combatExitSeconds + "s enter=" + s.combatEnterDelaySeconds + + "s fade=" + s.bottomMenuFadeSeconds + "s canvasGroup=" + s.bottomMenuUseCanvasGroup); + + sb.AppendLine(); + sb.AppendLine("[1] 하단 메뉴 연결 + 복귀 버튼 생성(런타임 · 프리팹 diff 0)"); + HudCombatVisibility.Teardown(); + HudCombatVisibility.ResetState(); + sb.AppendLine(" " + HudCombatVisibility.Bind(root.transform)); + sb.AppendLine(" " + HudCombatVisibility.Subscribe(true)); + + sb.AppendLine(); + sb.AppendLine("[2] idle(메뉴 보임) → 전투(메뉴 숨김) → 다시 idle · 도달 px 실측"); + // 🔴 Signal() 은 Time.unscaledTime 을 쓴다 — 프로브 클록도 같은 축이어야 한다(임의 t 를 넣으면 since 가 수천 초로 뜬다). + float t = Time.unscaledTime; + sb.AppendLine(" ⓪ idle " + HudCombatVisibility.Tick(t)); + sb.AppendLine(" pad " + pad.Apply()); + float idleReach = pad.LastActionReachPx, idleMax = pad.LastMaxReachPx; + sb.AppendLine(" ① 전투 신호 " + HudCombatVisibility.Signal("probe:attack")); + t = Time.unscaledTime; + sb.AppendLine(" tick " + HudCombatVisibility.Tick(t)); + sb.AppendLine(" pad " + pad.Apply()); + float fightReach = pad.LastActionReachPx, fightMax = pad.LastMaxReachPx; + sb.AppendLine(" 메뉴 α=" + HudCombatVisibility.MenuAlpha.ToString("F2") + "(fade " + s.bottomMenuFadeSeconds + "s 시작)" + + " 복귀버튼 표시=" + HudCombatVisibility.RestoreButtonVisible + + " 메뉴 blocksRaycasts=" + MenuBlocks(root.transform, s)); + HudCombatVisibility.Tick(t + s.bottomMenuFadeSeconds + 0.01f); + sb.AppendLine(" +" + s.bottomMenuFadeSeconds + "s → 메뉴 α=" + HudCombatVisibility.MenuAlpha.ToString("F2") + + "(기대 0.00) blocksRaycasts=" + MenuBlocks(root.transform, s)); + sb.AppendLine(" ② 전투 종료(" + s.combatExitSeconds + "s 무신호) " + HudCombatVisibility.Tick(t + s.combatExitSeconds + 0.01f)); + HudCombatVisibility.Tick(t + s.combatExitSeconds + s.bottomMenuFadeSeconds + 0.02f); + sb.AppendLine(" 메뉴 α=" + HudCombatVisibility.MenuAlpha.ToString("F2") + "(기대 1.00) menuVisible=" + HudCombatVisibility.BottomMenuVisible); + sb.AppendLine(" pad " + pad.Apply()); + sb.AppendLine(" ③ 복귀 버튼 클릭(전투 중이어도 되돌린다)"); + HudCombatVisibility.Signal("probe:attack2"); + t = Time.unscaledTime; + HudCombatVisibility.Tick(t); + sb.AppendLine(" 숨김 확인 menuVisible=" + HudCombatVisibility.BottomMenuVisible + "(기대 False) 복귀버튼 표시=" + HudCombatVisibility.RestoreButtonVisible); + sb.AppendLine(" " + HudCombatVisibility.OnRestoreClicked()); + sb.AppendLine(" menuVisible=" + HudCombatVisibility.BottomMenuVisible + " (기대 True)"); + HudCombatVisibility.Tick(t + 0.01f); + sb.AppendLine(" hold " + s.restoreHoldSeconds + "s 안에는 전투여도 유지 → menuVisible=" + HudCombatVisibility.BottomMenuVisible); + + sb.AppendLine(); + sb.AppendLine("[3] 엄지 도달 실측 (발주서 §1-6 목표 ≤ 490 px · 813m 오버레이 747 px 대비)"); + sb.AppendLine(" idle(메뉴 보임) actionReach=" + idleReach.ToString("F1") + " px · maxReach(자동토글 포함)=" + idleMax.ToString("F1") + " px"); + sb.AppendLine(" 전투(메뉴 숨김) actionReach=" + fightReach.ToString("F1") + " px · maxReach(자동토글 포함)=" + fightMax.ToString("F1") + " px"); + sb.AppendLine(" 판정: 전투 중 " + fightReach.ToString("F1") + " ≤ 490 → " + (fightReach <= 490f ? "PASS" : "FAIL") + + " (813m 실측 747 px 대비 " + (747f - fightReach).ToString("F1") + " px 감소)"); + + sb.AppendLine(); + sb.AppendLine("[4] C8 롤백 — combatHideBottomMenu=false 면 메뉴를 건드리지 않고 패드도 idle 자리(813c+Lead 핫픽스 상태)"); + bool k0 = s.combatHideBottomMenu; + s.combatHideBottomMenu = false; + HudCombatVisibility.Signal("probe:rollback"); + sb.AppendLine(" " + HudCombatVisibility.Tick(Time.unscaledTime)); + sb.AppendLine(" " + pad.Apply()); + sb.AppendLine(" menuVisible=" + HudCombatVisibility.BottomMenuVisible + "(기대 True · 전투 신호가 있어도 안 숨긴다)" + + " 메뉴 α=" + HudCombatVisibility.MenuAlpha.ToString("F2") + + " actionReach=" + pad.LastActionReachPx.ToString("F1") + " px(= 롤백 전 746.8)"); + s.combatHideBottomMenu = k0; + + sb.AppendLine(); + sb.AppendLine("[5] 최종 덤프"); + sb.Append(HudCombatVisibility.Dump()); + sb.Append(pad.Dump()); + } + finally + { + HudCombatVisibility.Teardown(); + KillRunners(); + PrefabUtility.UnloadPrefabContents(root); + } + sb.AppendLine(); + sb.AppendLine("정리: 구독=" + HudCombatVisibility.Subscribed + " 연결=" + HudCombatVisibility.Bound + + " · AttackStarted 구독자=" + WL.Combat.Core.CombatEvents.AttackStarted.Count + + " Damaged=" + WL.Combat.Core.CombatEvents.Damaged.Count + " (0 이어야 한다)"); + var body = sb.ToString(); + return body + "\n→ " + Write("WL813y_hud.txt", body); + } +} diff --git a/Assets/Script/HUD/HUDDMGUI.cs b/Assets/Script/HUD/HUDDMGUI.cs index 5cecd007b..1ac8fb947 100644 --- a/Assets/Script/HUD/HUDDMGUI.cs +++ b/Assets/Script/HUD/HUDDMGUI.cs @@ -1,4 +1,8 @@ // WL-813g (#813) — 데미지 숫자 리듬화. 원본(힘민지) HUDDMGUI 에 표시 규칙만 얹는다. +// WL-813y (#813) — Q3 결함 D-6: 밝은 배경에서 숫자 대비 부족 → TMP 머티리얼 아웃라인 + 그림자. +// · uGUI Outline/Shadow(BaseMeshEffect)는 TMP 에 적용되지 않으므로 WLTextFxUtil.ApplyEdge 를 쓴다. +// · 변형 머티리얼은 (원본 머티리얼 + 값) 조합마다 **1장만** 캐시해 공유한다 = 배칭 유지 · 인스턴스 0. +// · 인스턴스당 1회만 적용(m_edgeDone)해 표시 경로(SetCore)에 GC 를 넣지 않는다. // 기준서 v1 §B 요소 2: 일반 0.6 s · 크리 1.0 s · 크리 1.6배 · 합산 윈도우 0.25 s · 동시 상한(숫자죽 방지). // 값은 전부 Assets/WL/UI/Settings/Resources/WL/WLCombatTextSettings.asset (C45 · 코드 상수 0). // 🔴 C8 롤백: 에셋이 없거나 textEnabled/damageRhythmEnabled = false 면 **아래 원본 상수**(0.9 s · 배율 1 · 상한 없음) @@ -27,6 +31,11 @@ public class HUDDMGUI : HUDBase public static int EvictedCount, MergedCount; public static void ResetDiagnostics() { EvictedCount = MergedCount = 0; } + bool m_edgeDone; // WL-813y 아웃라인을 이 인스턴스에 이미 입혔는가 + /// 프로브가 읽는다 — 아웃라인을 입힌 인스턴스 수 · 마지막 실측 문자열. + public static int EdgeAppliedCount; + public static string LastEdgeDump = ""; + bool m_wl; // 이번 표시에 813g 규칙이 적용됐는가 bool m_crit; // 크리티컬로 표시 중인가 float m_life, m_lifeMax; // 남은/전체 표시 시간(unscaled 아님 — 원본과 같은 Time.deltaTime 계열) @@ -139,10 +148,32 @@ public class HUDDMGUI : HUDBase Register(null); } + ApplyEdge(st); Redraw(TextColor, st); isUpdate = false; } + /// WL-813y — TMP 아웃라인/그림자(값은 WLCombatTextSettings.damageEdge · 인스턴스당 1회). + void ApplyEdge(WL.UI.WLCombatTextSettings st) + { + if (m_edgeDone || dmgs == null) return; + if (st == null) { m_edgeDone = true; return; } // 설정 off = 원본 머티리얼 그대로 + var e = st.Edge(st.damageEdge); + for (int i = 0; i < dmgs.Length; i++) + if (dmgs[i] != null) LastEdgeDump = WL.UI.WLTextFxUtil.ApplyEdge(dmgs[i], e); + m_edgeDone = true; + EdgeAppliedCount++; + } + + /// 프로브 — 설정을 바꾼 뒤 살아 있는 인스턴스 전체에 다시 입힌다. + public static string RefreshEdges() + { + var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + EdgeAppliedCount = 0; + for (int i = 0; i < all.Length; i++) { all[i].m_edgeDone = false; all[i].ApplyEdge(St); } + return "HUDDMGUI 인스턴스 " + all.Length + "개 중 적용 " + EdgeAppliedCount + " · " + LastEdgeDump; + } + /// 합산 윈도우 안의 추가 타격 — 숫자를 더하고, 크리가 섞이면 크리 규격으로 승격한다. void Accumulate(double Damage, eStat Critical, WL.UI.WLCombatTextSettings st) { diff --git a/Assets/WL/UI/Scripts/BossBanner.cs b/Assets/WL/UI/Scripts/BossBanner.cs index 8f83f6048..c529913ea 100644 --- a/Assets/WL/UI/Scripts/BossBanner.cs +++ b/Assets/WL/UI/Scripts/BossBanner.cs @@ -79,9 +79,56 @@ namespace WL.UI HideNow(); var s = WLBossUiSettings.Instance; Subscribe(s == null || s.bannerEnabled); - return "initialized subscribed=" + _subscribed + " · " + layout; + string late = TriggerExistingBoss(); // WL-813y — 늦은 구독 보정(Q3 결함 D-3 · 813c 보스 바와 동일) + return "initialized subscribed=" + _subscribed + " · " + layout + " · " + late; } + /// + /// 배너가 켜질 때 **이미 살아 있는 보스**가 있으면 배너를 띄운다 (WL-813y). + /// 필드 보스는 맵 로드 시(`FieldBossData.Start()`) 스폰돼 `Spawned(isBoss)` 가 구독 전에 지나간다 + /// → 813c 보스 HP 바(`BossHpBar.BindExistingBoss`)와 **같은 조회**를 쓴다. + /// + public string TriggerExistingBoss() + { + var s = WLBossUiSettings.Instance; + if (s != null && !s.bannerEnabled) return "늦은구독: bannerEnabled=false — 생략"; + if (s != null && !s.bannerTriggerExisting) return "늦은구독: bannerTriggerExisting=false — 생략"; + + Actor found = null; + string how = ""; + var arenaBoss = WL.Combat.Boss.BossArena.Boss; + if (IsAliveBoss(arenaBoss)) { found = arenaBoss; how = "BossArena.Boss"; } + 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 + ")"; + + LateTriggered = true; + // 이름은 813h BossPhase 가 오면 승격된다(813tj 실측) — 여기서는 오브젝트 이름으로 먼저 띄운다. + return "늦은구독 " + how + " → " + Trigger(found.name, false); + } + + 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 LateTriggered { get; private set; } + /// 마지막 씬 스캔에서 본 MobActor 수(진단). + public int LateScanCount { get; private set; } + public void Subscribe(bool on) { if (on == _subscribed) return; @@ -420,7 +467,7 @@ namespace WL.UI " scale=" + BannerScale.ToString("F3") + " name=\"" + _shownName + "\"(known=" + _nameKnown + ")" + " seen(Spawned/Boss/Phase)=" + SpawnedSeen + "/" + BossSpawnSeen + "/" + PhaseSeen + - " shows=" + ShowCount); + " shows=" + ShowCount + " 늦은구독=" + LateTriggered + "(스캔 " + LateScanCount + ")"); sb.AppendLine(" 구독자 Spawned=" + CombatEvents.Spawned.Count + " BossPhase=" + BossEvents.BossPhase.Count + " CombatEvents.Enabled=" + CombatEvents.Enabled + " BossEvents.Enabled=" + BossEvents.Enabled); if (bannerBox != null) diff --git a/Assets/WL/UI/Scripts/BossHpBar.cs b/Assets/WL/UI/Scripts/BossHpBar.cs index d7a6771d7..41266ffe5 100644 --- a/Assets/WL/UI/Scripts/BossHpBar.cs +++ b/Assets/WL/UI/Scripts/BossHpBar.cs @@ -78,9 +78,63 @@ namespace WL.UI string layout = ApplyLayout(); HideNow(); Subscribe(true); - return "initialized subscribed=" + _subscribed + " · " + layout; + string late = BindExistingBoss(); // WL-813y — 늦은 구독 보정(Q3 결함 D-3) + return "initialized subscribed=" + _subscribed + " · " + layout + " · " + 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) { @@ -409,7 +463,9 @@ namespace WL.UI 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); + " 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(" CombatEvents subscribers Spawned=" + CombatEvents.Spawned.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count + " Killed=" + CombatEvents.Killed.Count + " Enabled=" + CombatEvents.Enabled); diff --git a/Assets/WL/UI/Scripts/HudCombatVisibility.cs b/Assets/WL/UI/Scripts/HudCombatVisibility.cs new file mode 100644 index 000000000..e3039e870 --- /dev/null +++ b/Assets/WL/UI/Scripts/HudCombatVisibility.cs @@ -0,0 +1,367 @@ +// ───────────────────────────────────────────────────────────────────────────── +// HudCombatVisibility.cs — 전투 중 하단 메뉴 숨김 + 전투 패드 원점 복귀 (WL-813y · #813) +// +// 근거 = Q3 `qa/W2-3_바퀴2.md` 결함 D-5: +// Lead 핫픽스가 하단 메뉴(`IngameUIs/WL_HUD/HUD_BottomMenu`) 겹침을 피하려고 +// `fanOriginPx.y 0 → 260` 을 넣어 **엄지 도달이 565 → 746.8 px 로 더 나빠졌다**(목표 320). +// +// ■ 해법 = 겹칠 상대를 전투 중에는 치운다 +// · 전투 신호(적 인지 · 피격 · 공격 · 스킬 · 처치)가 오면 하단 메뉴를 접고(CanvasGroup α 0) +// 전투 패드를 **모서리 원점**(`fanOriginPx` = 에셋 0,0 / `attackCenterPx` = 100,100)으로 되돌린다. +// · 마지막 신호 뒤 `combatExitSeconds` 가 지나면 메뉴가 돌아오고 패드는 idle 자리로 올라간다. +// · 숨은 동안 **복귀 버튼 1개**(런타임 생성)를 띄워 언제든 메뉴를 되돌릴 수 있다. +// +// ■ 프리팹 diff 0 +// 노드를 굽지 않는다 — 러너도 복귀 버튼도 **런타임 생성**(`HideFlags.DontSave`). +// `NewGameUI.prefab` 은 이 기능 때문에 1줄도 바뀌지 않는다(813c/813g/813tj 와 다른 점). +// +// ■ 값 = WLHudLayoutSettings.asset (C45 · 코드 상수 0) · C8 롤백 = `combatHideBottomMenu = 0` +// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Text; +using TMPro; +using UnityEngine; +using UnityEngine.UI; +using WL.Combat.Core; + +namespace WL.UI +{ + public static class HudCombatVisibility + { + // ── 상태 ────────────────────────────────────────────────────────────── + private static bool s_subscribed; + private static float s_lastCombatAt = -9999f; + private static float s_firstSignalAt = -9999f; + private static float s_restoreHeldUntil = -9999f; + private static bool s_menuVisible = true; // 초기값 = 원본(보임) + private static float s_alpha = 1f; + private static Transform s_menu; + private static CanvasGroup s_menuGroup; + private static Transform s_uiRoot; + private static RectTransform s_restore; + private static TextMeshProUGUI s_restoreLabel; + private static Image s_restoreBg; + + /// 하단 메뉴가 지금 보이는가 — 전투 패드 원점(WLHudLayoutSettings)이 이 값을 본다. + public static bool BottomMenuVisible { get { return s_menuVisible; } } + public static bool InCombat { get; private set; } + public static bool Subscribed { get { return s_subscribed; } } + public static float LastCombatAt { get { return s_lastCombatAt; } } + public static int HideCount, ShowCount, RestoreClickCount, SignalCount; + public static string LastSignal = ""; + public static bool Bound { get { return s_menu != null; } } + public static float MenuAlpha { get { return s_alpha; } } + public static bool RestoreButtonVisible { get { return s_restore != null && s_restore.gameObject.activeSelf; } } + + private static WLHudLayoutSettings St { get { return WLHudLayoutSettings.Instance; } } + + // ── 부팅 (노드 0 · 프리팹 diff 0) ───────────────────────────────────── + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void Boot() + { + var s = St; + if (s == null || !s.combatHideBottomMenu) return; + Subscribe(true); + HudCombatVisibilityRunner.Ensure(); + } + + // ── 전투 신호 구독 ──────────────────────────────────────────────────── + public static string Subscribe(bool on) + { + if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; + if (on) + { + CombatEvents.AttackStarted.Add(OnAttack); + CombatEvents.SkillCast.Add(OnSkill); + CombatEvents.Damaged.Add(OnDamaged); + CombatEvents.Killed.Add(OnKilled); + } + else + { + CombatEvents.AttackStarted.Remove(OnAttack); + CombatEvents.SkillCast.Remove(OnSkill); + CombatEvents.Damaged.Remove(OnDamaged); + CombatEvents.Killed.Remove(OnKilled); + } + s_subscribed = on; + return "구독=" + on + " (AttackStarted/SkillCast/Damaged/Killed)"; + } + + private static void OnAttack(in AttackStartedEvent e) { if (e.isMainPC) Signal("attack"); } + private static void OnSkill(in SkillCastEvent e) { if (IsMine(e.actor)) Signal("skill"); } + private static void OnDamaged(in DamagedEvent e) { if (IsMine(e.victim) || IsMine(e.attacker)) Signal("damaged"); } + private static void OnKilled(in KilledEvent e) { if (IsMine(e.killer)) Signal("killed"); } + + private static bool IsMine(Actor a) + { + if (a == null) return false; + if (!MyValue.bMyPC) return false; + return ReferenceEquals(a, MyValue.MyPC); + } + + /// 전투 신호 1건(프로브도 이 경로를 쓴다). + public static string Signal(string reason) + { + float now = Time.unscaledTime; + if (s_lastCombatAt < 0f || now - s_lastCombatAt > 0.5f) s_firstSignalAt = now; + s_lastCombatAt = now; + SignalCount++; + LastSignal = reason; + return "signal=" + reason + " t=" + now.ToString("F2"); + } + + /// 현재 타깃이 살아 있으면 전투로 본다(적 인지). + private static bool HasLiveTarget() + { + var s = St; + if (s == null || !s.combatUseTargetAsSignal) return false; + if (!MyValue.bMyPC || MyValue.MyPC == null) return false; + var t = MyValue.MyPC.Get_Target(); + return t != null && !t.IsDead(); + } + + // ── 매 프레임 판정 (러너가 부른다 · 프로브는 now 를 직접 준다) ──────── + public static string Tick(float now) + { + var s = St; + if (s == null) return "WLHudLayoutSettings 에셋 없음"; + if (!s.combatHideBottomMenu) + { + if (!s_menuVisible) SetMenuVisible(true, now, true); + return "combatHideBottomMenu=false — 무동작"; + } + + if (HasLiveTarget()) { s_lastCombatAt = now; if (s_firstSignalAt < 0f) s_firstSignalAt = now; LastSignal = "target"; } + + float since = now - s_lastCombatAt; + bool combat = since <= Mathf.Max(0f, s.combatExitSeconds) && + (now - s_firstSignalAt) >= Mathf.Max(0f, s.combatEnterDelaySeconds); + InCombat = combat; + + bool wantVisible = !combat; + if (now < s_restoreHeldUntil) wantVisible = true; // 복귀 버튼을 누른 직후에는 전투여도 보인다 + + if (wantVisible != s_menuVisible) SetMenuVisible(wantVisible, now, false); + StepFade(s, now); + return "combat=" + combat + " since=" + since.ToString("F2") + "s menuVisible=" + s_menuVisible + + " alpha=" + s_alpha.ToString("F2"); + } + + private static float s_fadeFrom, s_fadeAt; + + private static void StepFade(WLHudLayoutSettings s, float now) + { + float want = s_menuVisible ? 1f : 0f; + float dur = Mathf.Max(0f, s.bottomMenuFadeSeconds); + float a = dur <= 0f ? want : Mathf.Lerp(s_fadeFrom, want, Mathf.Clamp01((now - s_fadeAt) / dur)); + if (!Mathf.Approximately(a, s_alpha)) { s_alpha = a; PushAlpha(s); } + } + + private static void PushAlpha(WLHudLayoutSettings s) + { + if (s_menu == null) return; + if (s.bottomMenuUseCanvasGroup) + { + // 🔴 `??` 는 UnityEngine.Object 의 "가짜 null"(파괴됨 · 컴포넌트 없음)을 걸러 내지 못한다 — + // GetComponent 결과는 반드시 오버로드된 `==` 로 검사한다(813y 프로브가 잡은 MissingComponentException). + if (s_menuGroup == null) + { + var cg = s_menu.GetComponent(); + s_menuGroup = cg != null ? cg : s_menu.gameObject.AddComponent(); + } + s_menuGroup.alpha = s_alpha; + s_menuGroup.blocksRaycasts = s_alpha > 0.05f; + s_menuGroup.interactable = s_alpha > 0.05f; + } + else + { + bool on = s_alpha > 0.05f; + if (s_menu.gameObject.activeSelf != on) s_menu.gameObject.SetActive(on); + } + } + + /// 메뉴 표시 상태를 바꾸고 전투 패드를 다시 앉힌다. + public static string SetMenuVisible(bool visible, float now, bool instant) + { + var s = St; + s_fadeFrom = s_alpha; + s_fadeAt = now; + s_menuVisible = visible; + if (visible) ShowCount++; else HideCount++; + if (instant || s == null || s.bottomMenuFadeSeconds <= 0f) { s_alpha = visible ? 1f : 0f; if (s != null) PushAlpha(s); } + + SetRestoreVisible(!visible); + string pad = ReapplyPad(); + return "menuVisible=" + visible + " · " + pad; + } + + /// 패드 재배치 — 원점이 메뉴 상태에 따라 바뀐다(WLHudLayoutSettings.FanOriginPxFor). + public static string ReapplyPad() + { + var pads = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + if (pads == null || pads.Length == 0) return "WLBattlePadLayout 없음(패드 재배치 생략)"; + var sb = new StringBuilder(); + for (int i = 0; i < pads.Length; i++) sb.Append(i > 0 ? " | " : "").Append(pads[i].Apply()); + return sb.ToString(); + } + + // ── 노드 연결 · 복귀 버튼(런타임 생성) ──────────────────────────────── + /// NewGameUI 루트에서 하단 메뉴를 찾고 복귀 버튼을 만든다. 이미 물려 있으면 그대로. + public static string Bind(Transform uiRoot) + { + var s = St; + if (s == null) return "WLHudLayoutSettings 에셋 없음"; + if (uiRoot == null) return "uiRoot=null"; + if (s_menu != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_menu.name; + + s_uiRoot = uiRoot; + s_menu = WLVignetteUtil.FindUiPath(uiRoot, s.bottomMenuPath); + s_menuGroup = null; + if (s_menu == null) return "하단 메뉴 없음 — 경로 \"" + s.bottomMenuPath + "\""; + + s_alpha = s_menuVisible ? 1f : 0f; + PushAlpha(s); + string btn = BuildRestore(s); + SetRestoreVisible(!s_menuVisible); + return "연결 " + s.bottomMenuPath + " (자식 " + s_menu.childCount + ") · " + btn; + } + + private static string BuildRestore(WLHudLayoutSettings s) + { + if (!s.bottomMenuRestoreButton) { DestroyRestore(); return "복귀 버튼 off"; } + if (s_menu == null) return "복귀 버튼 — 부모 없음"; + + var parent = s_menu.parent as RectTransform; // WL_HUD (SafeAreaFitter 아래 = Safe Area 안) + if (parent == null) return "복귀 버튼 — 부모가 RectTransform 이 아님"; + + if (s_restore == null) + { + var go = new GameObject("WL_BottomMenuRestore", typeof(RectTransform)); + go.layer = WLTextFxUtil.UILayer; + go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다 + s_restore = (RectTransform)go.transform; + s_restore.SetParent(parent, false); + s_restoreBg = go.AddComponent(); + s_restoreBg.raycastTarget = true; + var b = go.AddComponent