[WL-813y] UI 핫픽스 묶음 (#813)

Q3 `qa/W2-3_바퀴2.md` 결함 4·6·7·8 + 813tj 연결점 6줄 + 813k LevelUp 페이로드 + 813m 도달 747 px 대응.
재부팅으로 끊긴 이전 워커의 미커밋 작업을 인수해 이어서 완성했다.

1. 813i 연결 6줄 — WLSurvivalUiBridge(신규): PotionButton Provider 4 + ReviveDialog 2.
   Gameplay 소유 813i API 를 UI 쪽에서 호출만 한다(Assets/WL/Combat/** 수정 0).
2. 스탯 팝업 — StatPopup 이 813k GrowthEvents.LevelUp 을 구독(811b 코어 축과 택일 = 이중 팝업 0).
   스탯 변화 0 이면 "+5 포인트(보유 N)" 줄로 떨어진다(813k 보고 ①).
3. 토스트 가시성 — 지속 1.2 → 2.0 s · 글자 40 → 52 px · 줄 뒤 반투명 패널 · 등급 1 회색(#a6a6a7)
   밝기 보정(#F2F5FA) · 중심 오프셋 +120 → -120 px(스탯 +180 · 연쇄 +520 대역 회피).
4. 텍스트 아웃라인 — uGUI Outline/Shadow 는 TMP 에 안 먹으므로 TMP 머티리얼 아웃라인 +
   언더레이 그림자를 쓴다. (원본 머티리얼 + 값) 조합마다 1장 캐시 공유 = 라벨별 인스턴스 0.
5. 보스 HP 바·배너 늦은 구독 보정 — 켜질 때 BossArena.Boss → 없으면 MobActor 1회 스캔.
6. 전투 중 하단 메뉴 숨김 + 패드 원점 복귀 — HudCombatVisibility(신규 · 노드는 런타임 생성,
   프리팹 diff 0) · fanOriginPx (0,260) → (0,0) · attackCenterPx (100,360) → (100,100).
   엄지 도달 실측 746.8 → **490.0 px**(발주서 ≤ 490 PASS).

수정 중 실측으로 잡은 버그 2건: UnityEngine.Object 가짜 null 을 `??` 가 못 걸러
HUD_BottomMenu 에 CanvasGroup 이 붙지 않던 MissingComponentException · C8 롤백
(combatHideBottomMenu=false)이 모서리 원점을 돌려줘 메뉴와 다시 겹치던 문제.

값은 전부 SO(C8/C45) · 프리팹 0 · Gameplay/Systems 경로 0 · 핫스팟 0 · Play 0.
This commit is contained in:
깃 관리자 2026-09-09 07:58:46 +09:00
parent f408618b31
commit 3d4936cf36
21 changed files with 2090 additions and 30 deletions

View File

@ -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>(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>(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 포인트 <size=70%>(보유 {0})</size>";
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>(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();
}
}

View File

@ -0,0 +1,636 @@
// WL-813y 프로브 — UI 핫픽스 묶음 실측(에디트 모드 · Play 0).
// run_script --file AgentScripts/WL813y_Probe.cs --entry WL813y_Probe.<Entry>
// Nodes · Bridge · Stat · Text · BossLate · Hud
// 로그는 <worktree>/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<Transform>(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<SafeAreaFitter>() != null));
foreach (Transform c in hud)
{
var comps = c.GetComponents<Component>();
var names = new List<string>();
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<ReviveDialog>() : null;
var potion = pad != null ? pad.GetComponentInChildren<PotionButton>(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<PCActor>();
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<MonoBehaviour>(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<Actor>(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<StatPopup>() : 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<LootToast>();
kc = hud.Find("WL_KillChainText").GetComponent<KillChainText>();
sp = hud.Find("WL_StatPopup").GetComponent<StatPopup>();
float u = WLVignetteUtil.UnitsPerPx(hud.GetComponentInParent<Canvas>());
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<RectTransform>();
sb.AppendLine(" 루트 pos" + ltRt.anchoredPosition + " size" + ltRt.sizeDelta +
" aMin" + ltRt.anchorMin + " aMax" + ltRt.anchorMax +
" · SafeArea 안(부모 WL_HUD SafeAreaFitter=" +
(hud.GetComponent<SafeAreaFitter>() != 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<GameObject>("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<HUDDMGUI>();
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<int, int>();
var matName = new Dictionary<int, string>();
foreach (var t in root.GetComponentsInChildren<TMP_Text>(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<BossHpBar>();
ban = hud.Find("WL_BossBanner").GetComponent<BossBanner>();
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<BossHpBar>();
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<BossHpBar>();
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<BossHpBar>();
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<CanvasGroup>();
return cg == null ? "CanvasGroup 없음🔴" : cg.blocksRaycasts.ToString();
}
static MobActor MakeBoss()
{
var go = new GameObject("__WL813y_Boss");
var m = go.AddComponent<MobActor>();
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<WLBattlePadLayout>() : null;
if (pad == null) pad = root.GetComponentInChildren<WLBattlePadLayout>(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);
}
}

View File

@ -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 아웃라인을 이 인스턴스에 이미 입혔는가
/// <summary>프로브가 읽는다 — 아웃라인을 입힌 인스턴스 수 · 마지막 실측 문자열.</summary>
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;
}
/// <summary>WL-813y — TMP 아웃라인/그림자(값은 WLCombatTextSettings.damageEdge · 인스턴스당 1회).</summary>
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++;
}
/// <summary>프로브 — 설정을 바꾼 뒤 살아 있는 인스턴스 전체에 다시 입힌다.</summary>
public static string RefreshEdges()
{
var all = Object.FindObjectsByType<HUDDMGUI>(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;
}
/// <summary>합산 윈도우 안의 추가 타격 — 숫자를 더하고, 크리가 섞이면 크리 규격으로 승격한다.</summary>
void Accumulate(double Damage, eStat Critical, WL.UI.WLCombatTextSettings st)
{

View File

@ -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;
}
/// <summary>
/// 배너가 켜질 때 **이미 살아 있는 보스**가 있으면 배너를 띄운다 (WL-813y).
/// 필드 보스는 맵 로드 시(`FieldBossData.Start()`) 스폰돼 `Spawned(isBoss)` 가 구독 전에 지나간다
/// → 813c 보스 HP 바(`BossHpBar.BindExistingBoss`)와 **같은 조회**를 쓴다.
/// </summary>
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<MobActor>(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<MobActor> " + 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;
}
/// <summary>늦은 구독 보정으로 배너를 띄웠는가(진단).</summary>
public bool LateTriggered { get; private set; }
/// <summary>마지막 씬 스캔에서 본 MobActor 수(진단).</summary>
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)

View File

@ -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;
}
/// <summary>
/// 바가 켜질 때 **이미 살아 있는 보스**가 있으면 즉시 물린다 (WL-813y · Q3 결함 D-3).
///
/// 필드 보스는 `FieldBossData.Start()` 로 **맵 로드 시** 스폰돼 `Spawned(isBoss)` 가
/// HUD 가 구독하기 **전에** 지나간다(Q3 실측: `SpawnedSeen=232 · _bound=False`).
/// 813d 가 만든 `BossArena.Boss`(게이트가 잡아 둔 현재 보스)를 1순위로 보고,
/// 없으면 씬에서 `MobActor` 를 1회만 훑어 `IsSubRole(eSubRol.Boss)` 인 살아 있는 개체를 찾는다.
/// </summary>
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<MobActor>(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<MobActor> " + 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;
}
/// <summary>늦은 구독 보정으로 물었는가(진단).</summary>
public bool LateBound { get; private set; }
/// <summary>마지막 씬 스캔에서 본 MobActor 수(진단).</summary>
public int LateScanCount { get; private set; }
/// <summary>구독 등록/해제. OnEnable/OnDisable 이 부르고, 에디터 검증도 같은 것을 쓴다.</summary>
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);

View File

@ -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;
/// <summary>하단 메뉴가 지금 보이는가 — 전투 패드 원점(WLHudLayoutSettings)이 이 값을 본다.</summary>
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);
}
/// <summary>전투 신호 1건(프로브도 이 경로를 쓴다).</summary>
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");
}
/// <summary>현재 타깃이 살아 있으면 전투로 본다(적 인지).</summary>
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<CanvasGroup>();
s_menuGroup = cg != null ? cg : s_menu.gameObject.AddComponent<CanvasGroup>();
}
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);
}
}
/// <summary>메뉴 표시 상태를 바꾸고 전투 패드를 다시 앉힌다.</summary>
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;
}
/// <summary>패드 재배치 — 원점이 메뉴 상태에 따라 바뀐다(WLHudLayoutSettings.FanOriginPxFor).</summary>
public static string ReapplyPad()
{
var pads = Object.FindObjectsByType<WLBattlePadLayout>(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();
}
// ── 노드 연결 · 복귀 버튼(런타임 생성) ────────────────────────────────
/// <summary>NewGameUI 루트에서 하단 메뉴를 찾고 복귀 버튼을 만든다. 이미 물려 있으면 그대로.</summary>
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<Image>();
s_restoreBg.raycastTarget = true;
var b = go.AddComponent<Button>();
b.targetGraphic = s_restoreBg;
b.onClick.AddListener(() => OnRestoreClicked()); // UnityAction = void() — 반환값 있는 메서드는 람다로 감싼다
s_restoreLabel = WLTextFxUtil.NewText(s_restore, "Label", null, TextAlignmentOptions.Center);
WLTextFxUtil.Stretch(s_restoreLabel.rectTransform);
}
float u = WLVignetteUtil.UnitsPerPx(parent.GetComponentInParent<Canvas>());
s_restore.anchorMin = s_restore.anchorMax = new Vector2(0.5f, 0f);
s_restore.pivot = new Vector2(0.5f, 0.5f);
s_restore.anchoredPosition = new Vector2(s.restoreButtonCenterPx.x * u, s.restoreButtonCenterPx.y * u);
s_restore.sizeDelta = new Vector2(s.restoreButtonDiameterPx * u, s.restoreButtonDiameterPx * u);
s_restore.localScale = Vector3.one;
s_restoreBg.color = s.restoreButtonBgColor;
s_restoreLabel.text = s.restoreButtonLabel;
s_restoreLabel.color = s.restoreButtonLabelColor;
s_restoreLabel.fontSize = s.restoreButtonFontPx * u;
return "복귀 버튼 pos" + s_restore.anchoredPosition + " size" + s_restore.sizeDelta +
" (px " + s.restoreButtonCenterPx + " 지름 " + s.restoreButtonDiameterPx + " u=" + u.ToString("F4") + ")";
}
private static void SetRestoreVisible(bool on)
{
var s = St;
if (s_restore == null) return;
bool want = on && s != null && s.bottomMenuRestoreButton;
if (s_restore.gameObject.activeSelf != want) s_restore.gameObject.SetActive(want);
}
/// <summary>복귀 버튼 클릭 — 메뉴를 되돌리고 잠시 전투 판정을 무시한다.</summary>
public static string OnRestoreClicked()
{
var s = St;
RestoreClickCount++;
float now = Time.unscaledTime;
s_restoreHeldUntil = now + (s != null ? Mathf.Max(0f, s.restoreHoldSeconds) : 0f);
return SetMenuVisible(true, now, false) + " · hold " + (s != null ? s.restoreHoldSeconds : 0f) + "s";
}
private static void DestroyRestore()
{
if (s_restore == null) return;
if (Application.isPlaying) Object.Destroy(s_restore.gameObject); else Object.DestroyImmediate(s_restore.gameObject);
s_restore = null; s_restoreLabel = null; s_restoreBg = null;
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
/// <summary>상태를 초기값으로(에디트 모드 반복 실행 대비 · 노드는 남긴다).</summary>
public static string ResetState()
{
InCombat = false;
s_lastCombatAt = -9999f; s_firstSignalAt = -9999f; s_restoreHeldUntil = -9999f;
HideCount = ShowCount = RestoreClickCount = SignalCount = 0;
LastSignal = "";
s_menuVisible = true; s_alpha = 1f;
var s = St; if (s != null) PushAlpha(s);
SetRestoreVisible(false);
return "상태 초기화(메뉴 보임 · 패드 idle)";
}
/// <summary>연결·노드까지 되돌린다(프로브 종료용).</summary>
public static string Teardown()
{
Subscribe(false);
ResetState();
DestroyRestore();
s_menu = null; s_menuGroup = null; s_uiRoot = null;
return "teardown 완료";
}
public static string Dump()
{
var s = St;
var sb = new StringBuilder();
sb.AppendLine("[HudCombatVisibility] 구독=" + s_subscribed + " inCombat=" + InCombat +
" menuVisible=" + s_menuVisible + " alpha=" + s_alpha.ToString("F2") +
" hide=" + HideCount + " show=" + ShowCount + " restoreClick=" + RestoreClickCount +
" signals=" + SignalCount + "(last=" + LastSignal + ")");
sb.AppendLine(" 메뉴 = " + (s_menu == null ? "미연결" :
s_menu.name + " active=" + s_menu.gameObject.activeSelf + " 자식=" + s_menu.childCount +
" group=" + (s_menuGroup != null ? s_menuGroup.alpha.ToString("F2") : "-")));
sb.AppendLine(" 복귀버튼 = " + (s_restore == null ? "없음" :
"active=" + s_restore.gameObject.activeSelf + " pos" + s_restore.anchoredPosition +
" size" + s_restore.sizeDelta + " text=\"" + (s_restoreLabel != null ? s_restoreLabel.text : "") + "\""));
if (s != null)
sb.AppendLine(" 설정 enabled=" + s.combatHideBottomMenu + " exit=" + s.combatExitSeconds + "s enter=" +
s.combatEnterDelaySeconds + "s fade=" + s.bottomMenuFadeSeconds + "s canvasGroup=" +
s.bottomMenuUseCanvasGroup + " target신호=" + s.combatUseTargetAsSignal +
" · 원점 전투" + s.fanOriginPx + " idle" + s.idleFanOriginPx +
" 공격 전투" + s.attackCenterPx + " idle" + s.idleAttackCenterPx);
return sb.ToString();
}
}
/// <summary>매 프레임 <see cref="HudCombatVisibility.Tick"/> 을 부르는 런타임 전용 러너(씬에 굽지 않는다).</summary>
internal sealed class HudCombatVisibilityRunner : MonoBehaviour
{
private static HudCombatVisibilityRunner s_ins;
private NewGameUI _lastUi;
internal static void Ensure()
{
if (s_ins != null) return;
var go = new GameObject("WL_HudCombatVisibilityRunner");
go.hideFlags = HideFlags.HideAndDontSave;
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<HudCombatVisibilityRunner>();
}
private void Update()
{
var ui = NewGameUI.Ins;
if (ui != null && !ReferenceEquals(ui, _lastUi))
{
_lastUi = ui;
HudCombatVisibility.Bind(ui.transform);
}
HudCombatVisibility.Tick(Time.unscaledTime);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac5c1e39203b8f94f875435d6b356b67

View File

@ -51,6 +51,8 @@ namespace WL.UI
public bool Subscribed { get { return _subscribed; } }
public float LastScale { get; private set; }
public Vector2 LastAnchoredPos { get; private set; }
/// <summary>마지막으로 적용한 아웃라인/그림자 실측 문자열(WL-813y).</summary>
public string LastEdge { get; private set; } = "";
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
@ -127,8 +129,14 @@ namespace WL.UI
subLabel.fontSize = s.killChainFontPx * u;
subLabel.alignment = TextAlignmentOptions.Top;
// WL-813y — Q3 결함 D-6 ①: 밝은 모래 배경에서 금색 글자가 사라진다 → 아웃라인 + 드롭섀도.
var edge = s.Edge(s.killChainEdge);
LastEdge = WLTextFxUtil.ApplyEdge(mainLabel, edge);
WLTextFxUtil.ApplyEdge(subLabel, edge);
return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " unitsPerPx=" + u.ToString("F4") +
" fontPx=" + s.killChainFontPx + "→" + mainLabel.fontSize.ToString("F1");
" fontPx=" + s.killChainFontPx + "→" + mainLabel.fontSize.ToString("F1") +
" edge[" + LastEdge + "]";
}
// ───────────────────────────────────────────── 이벤트
@ -217,6 +225,7 @@ namespace WL.UI
" size=" + (box != null ? box.sizeDelta.ToString() : "-"));
sb.AppendLine(" fontSize=" + (mainLabel != null ? mainLabel.fontSize.ToString("F1") : "-") +
" color=" + (mainLabel != null ? ColorUtility.ToHtmlStringRGB(mainLabel.color) : "-"));
sb.AppendLine(" edge = " + LastEdge);
return sb.ToString();
}

View File

@ -23,6 +23,7 @@ using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Core;
namespace WL.UI
@ -40,6 +41,7 @@ namespace WL.UI
public RectTransform rt;
public TextMeshProUGUI label;
public CanvasGroup group;
public Image panel; // WL-813y — 밝은 배경 대비용 반투명 패널
public int itemId, count;
public float life, lifeMax, elapsed;
public bool used;
@ -58,6 +60,8 @@ namespace WL.UI
public int MergeCount { get; private set; }
public int DropCount { get; private set; } // 줄 상한 초과로 밀려난 수
public bool Subscribed { get { return _subscribed; } }
/// <summary>마지막으로 적용한 아웃라인/그림자 실측 문자열(WL-813y).</summary>
public string LastEdge { get; private set; } = "";
public int VisibleLines
{
get { int n = 0; for (int i = 0; i < _lines.Count; i++) if (_lines[i].used) n++; return n; }
@ -100,11 +104,16 @@ namespace WL.UI
{
int idx = _lines.Count;
var lrt = WLTextFxUtil.NewChild(lineRoot, "Line" + idx);
// 패널을 먼저 만든다 — uGUI 는 형제 순서가 곧 그리는 순서라 라벨이 위로 온다(813tj 교훈).
var prt = WLTextFxUtil.NewChild(lrt, "Panel");
var panel = prt.GetComponent<Image>();
if (panel == null) panel = prt.gameObject.AddComponent<Image>();
panel.raycastTarget = false;
var label = WLTextFxUtil.NewText(lrt, "Label", font, TextAlignmentOptions.Right);
var cg = lrt.GetComponent<CanvasGroup>();
if (cg == null) cg = lrt.gameObject.AddComponent<CanvasGroup>();
cg.interactable = false; cg.blocksRaycasts = false; cg.alpha = 0f;
_lines.Add(new Line { rt = lrt, label = label, group = cg });
_lines.Add(new Line { rt = lrt, label = label, group = cg, panel = panel });
made = true;
}
for (int i = want; i < _lines.Count; i++)
@ -141,6 +150,19 @@ namespace WL.UI
WLTextFxUtil.Stretch(l.label.rectTransform);
l.label.fontSize = s.lootToastFontPx * u;
l.label.alignment = TextAlignmentOptions.MidlineRight;
// WL-813y — 줄 뒤 패널(대비) + 글자 아웃라인. 값은 전부 SO.
if (l.panel != null)
{
bool on = s.lootToastPanelEnabled;
if (l.panel.enabled != on) l.panel.enabled = on;
l.panel.color = s.lootToastPanelColor;
var prt = l.panel.rectTransform;
prt.anchorMin = Vector2.zero; prt.anchorMax = Vector2.one; prt.pivot = new Vector2(0.5f, 0.5f);
prt.offsetMin = new Vector2(-s.lootToastPanelPadXPx * u, -s.lootToastPanelPadYPx * u);
prt.offsetMax = new Vector2(s.lootToastPanelPadXPx * u, s.lootToastPanelPadYPx * u);
}
LastEdge = WLTextFxUtil.ApplyEdge(l.label, s.Edge(s.lootToastEdge));
sb.Append(" · [").Append(i).Append("] pos=").Append(l.rt.anchoredPosition);
}
return sb.ToString();
@ -223,7 +245,7 @@ namespace WL.UI
private void Render(Line l, string name, int grade, WLCombatTextSettings s)
{
string colored = s.GradeColorTag(grade) + name;
string colored = s.GradeColorTagBright(grade) + name; // WL-813y — 등급 1 회색이 모래 배경에서 사라진다(Q3 D-6 ②)
l.label.text = l.count > 1
? DSUtil.Format(s.lootToastFormat, colored, l.count)
: DSUtil.Format(s.lootToastFormatSingle, colored);
@ -336,8 +358,18 @@ namespace WL.UI
sb.AppendLine(" [" + i + "] used=" + l.used + " item=" + l.itemId + " x" + l.count +
" alpha=" + l.group.alpha.ToString("F2") + " pos=" + l.rt.anchoredPosition +
" size=" + l.rt.sizeDelta + " font=" + l.label.fontSize.ToString("F1") +
" panel=" + (l.panel != null ? (l.panel.enabled ? "#" + ColorUtility.ToHtmlStringRGBA(l.panel.color) +
" off" + l.panel.rectTransform.offsetMin + l.panel.rectTransform.offsetMax : "off") : "없음") +
" text=\"" + l.label.text + "\"");
}
var st = WLCombatTextSettings.Instance;
if (st != null)
sb.AppendLine(" 설정 seconds=" + st.lootToastSeconds + "s fontPx=" + st.lootToastFontPx +
" widthPx=" + st.lootToastWidthPx + " lineHeightPx=" + st.lootToastLineHeightPx +
" rightMarginPx=" + st.lootToastRightMarginPx + " centerOffsetPx=" + st.lootToastCenterOffsetPx +
" panel=" + st.lootToastPanelEnabled + " 밝기하한=" + st.lootToastMinBrightness +
" · 등급1 태그 " + st.GradeColorTag(1) + " → " + st.GradeColorTagBright(1));
sb.AppendLine(" edge = " + LastEdge);
return sb.ToString();
}

View File

@ -51,6 +51,25 @@ namespace WL.UI
return Active.QueueShow(Time.unscaledTime);
}
// ── WL-813y — 813i DeathFlow 와 이어 붙인 진입점 2개 ──────────────────
/// <summary>
/// `DeathFlow.ReviveRequested` 진입점 — 813i 가 **사망 연출을 이미 기다렸으므로** 지연 없이 연다.
/// (`NotifyDeath()` 의 UI 지연과 겹쳐 두 번 뜨지 않도록 예약을 지우고 1회만 연다.)
/// </summary>
public static string NotifyReviveRequested()
{
if (Active == null) return "ReviveDialog 인스턴스 없음 — 무동작";
return Active.ShowNowOnce();
}
/// <summary>`DeathFlow.Revived` 진입점 — 부활이 끝났으니 팝업을 닫는다(자동/외부 부활 포함).</summary>
public static string NotifyRevived()
{
if (Active == null) return "ReviveDialog 인스턴스 없음 — 무동작";
Active.HideNow();
return "부활 완료 — 팝업 닫음";
}
[Header("폴백 팝업 구성 요소 (공용 Popup 이 없을 때만 쓴다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform box;
@ -66,6 +85,7 @@ namespace WL.UI
private RectTransform _rt;
private Canvas _canvas;
private float _showAt = -1f;
private bool _open; // 팝업이 열려 있는가(공용 Popup 경로 포함 · WL-813y)
// ── 진단 ──────────────────────────────────────────────────────────────
public int DeathSeen { get; private set; }
@ -112,11 +132,20 @@ namespace WL.UI
ShowNow();
}
/// <summary>예약을 지우고, 아직 안 떠 있을 때만 연다(WL-813y · 813i 이중 통지 방지).</summary>
public string ShowNowOnce()
{
_showAt = -1f;
if (_open) return "이미 표시 중 — 중복 무시(shows=" + ShownCount + ")";
return ShowNow();
}
/// <summary>지금 바로 팝업을 연다(공용 Popup 우선 · 실패하면 폴백 노드).</summary>
public string ShowNow()
{
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음";
_open = true;
string msg = BuildMessage(s);
LastMessage = msg;
@ -191,6 +220,7 @@ namespace WL.UI
public void HideNow()
{
_showAt = -1f;
_open = false;
if (group == null) return;
group.alpha = 0f; group.blocksRaycasts = false; group.interactable = false;
}
@ -327,7 +357,9 @@ namespace WL.UI
var s = WLSurvivalUiSettings.Instance;
sb.AppendLine("[ReviveDialog] deaths=" + DeathSeen + " shows=" + ShownCount + " confirms=" + ConfirmCount +
" 대기중=" + Pending + " 공용팝업사용=" + UsedCommonPopup +
" 폴백표시=" + FallbackVisible + " 813i(ReviveRequested)=" + (ReviveRequested != null));
" 폴백표시=" + FallbackVisible + " open=" + _open +
" 813i(ReviveRequested)=" + (ReviveRequested != null) +
" 브리지=" + WLSurvivalUiBridge.Installed);
sb.AppendLine(" 본문=\"" + LastMessage.Replace("\n", " / ") + "\"");
sb.AppendLine(" 공용 Popup.Ins=" + (Popup.Ins != null ? "있음" : "없음(에디트 모드/씬 미로드)"));
if (box != null)

View File

@ -21,6 +21,7 @@ using System.Text;
using TMPro;
using UnityEngine;
using WL.Combat.Core;
using WL.Combat.Growth;
namespace WL.UI
{
@ -47,7 +48,9 @@ namespace WL.UI
private readonly Dictionary<int, double> _snapshot = new Dictionary<int, double>();
private RectTransform _rt;
private bool _subscribed;
private bool _usingGrowth;
private float _life, _lifeMax, _elapsed;
private string _lastEdge = "";
/// <summary>813k(또는 다른 발생원)가 찾아 쓸 수 있는 현재 활성 팝업.</summary>
public static StatPopup Active { get; private set; }
@ -59,6 +62,13 @@ namespace WL.UI
public string LastText { get; private set; }
public bool Subscribed { get { return _subscribed; } }
public bool Visible { get { return group != null && group.alpha > 0f; } }
/// <summary>813k 축(GrowthEvents)을 물었는가. false = 811b 코어 축.</summary>
public bool UsingGrowthEvents { get { return _usingGrowth; } }
/// <summary>마지막 표시가 "포인트" 줄로 떨어졌는가(스탯 변화 0).</summary>
public bool UsedPointLine { get; private set; }
public int LastLevelsGained { get; private set; }
public int LastStatPointTotal { get; private set; }
public string LastEdge { get { return _lastEdge; } }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); if (Active == this) Active = null; }
@ -81,8 +91,20 @@ namespace WL.UI
public void Subscribe(bool on)
{
if (on == _subscribed) return;
if (on) CombatEvents.LevelUp.Add(OnLevelUp);
var s = WLCombatTextSettings.Instance;
bool growth = s == null || s.statPopupUseGrowthEvents;
if (on)
{
// 813k 축(스탯 3종 + 잔여 포인트)이 우선. 811b 코어 축은 813k 가 forwardToCombatEvents 로
// **같은 프레임에 또** 쏘므로, 둘 다 구독하면 팝업이 두 번 뜬다 → 하나만 문다.
if (growth) { GrowthEvents.LevelUp.Add(OnGrowthLevelUp); _usingGrowth = true; }
else { CombatEvents.LevelUp.Add(OnLevelUp); _usingGrowth = false; }
}
else
{
if (_usingGrowth) GrowthEvents.LevelUp.Remove(OnGrowthLevelUp);
else CombatEvents.LevelUp.Remove(OnLevelUp);
}
_subscribed = on;
}
@ -142,8 +164,15 @@ namespace WL.UI
_rows[i].fontSize = s.statPopupRowFontPx * u;
_rows[i].color = s.statPopupRowColor;
}
// WL-813y — 밝은 배경 대비(Q3 D-6 ①). 값은 SO · 포스트 0.
var edge = s.Edge(s.statPopupEdge);
_lastEdge = WLTextFxUtil.ApplyEdge(titleLabel, edge);
for (int i = 0; i < _rows.Count; i++) WLTextFxUtil.ApplyEdge(_rows[i], edge);
return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " rows=" + _rows.Count +
" unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1");
" unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1") +
" edge[" + _lastEdge + "]";
}
// ───────────────────────────────────────────── 이벤트 · 공개 API
@ -155,6 +184,38 @@ namespace WL.UI
Report(e.newLevel, BuildDeltasFromSnapshot(e.pc, s));
}
// ── 813k 축 (WL-813y §1-2 · Q3 결함 D-7) ──────────────────────────────
/// <summary>
/// 813k `GrowthEvents.LevelUp` 구독. 페이로드가 스탯 3종의 prev/new 와 잔여 스탯 포인트를 실어 온다
/// (813k 완료보고 §3). 원본은 레벨업으로 스탯을 올리지 않으므로 **prev == new 가 기본** —
/// 그때는 "포인트" 줄로 떨어진다(813k 보고 ①).
/// </summary>
private void OnGrowthLevelUp(in PlayerLevelUpEvent e)
{
var s = WLCombatTextSettings.Instance;
if (s == null || !WLCombatTextSettings.Enabled || !s.statPopupEnabled) return;
// 다단 레벨업(같은 Add_Exp 에서 여러 번)은 마지막 것만 남긴다 — 팝업이 겹쳐 깜빡이지 않도록.
LastLevelsGained = e.levelsGained;
LastStatPointTotal = e.statPointTotal;
Show(e.newLevel, BuildDeltasFromGrowth(in e, s), PointLine(s, e.statPointTotal));
}
private static WLStatDelta[] BuildDeltasFromGrowth(in PlayerLevelUpEvent e, WLCombatTextSettings s)
{
int n = Mathf.Clamp(s.statPopupRows, 1, 3);
var arr = new WLStatDelta[n];
if (n > 0) arr[0] = new WLStatDelta { name = e.statAKind.ToString(), prev = e.prevStatA, next = e.newStatA };
if (n > 1) arr[1] = new WLStatDelta { name = e.statBKind.ToString(), prev = e.prevStatB, next = e.newStatB };
if (n > 2) arr[2] = new WLStatDelta { name = e.statCKind.ToString(), prev = e.prevStatC, next = e.newStatC };
return arr;
}
private static string PointLine(WLCombatTextSettings s, int statPointTotal)
{
if (s == null || string.IsNullOrEmpty(s.statPopupPointFormat)) return null;
return DSUtil.Format(s.statPopupPointFormat, statPointTotal);
}
/// <summary>
/// 🔵 813k 연결점 — 이전/새 스탯을 그대로 실어 보내면 이 메서드가 팝업을 띄운다.
/// deltas 가 null 이면 컴포넌트가 스스로 스냅샷 차이를 만든다.
@ -166,7 +227,13 @@ namespace WL.UI
}
/// <summary>팝업을 띄운다(프로브도 이 경로를 쓴다).</summary>
public string Show(int newLevel, WLStatDelta[] deltas)
public string Show(int newLevel, WLStatDelta[] deltas) { return Show(newLevel, deltas, null); }
/// <summary>
/// 팝업을 띄운다. <paramref name="fallbackLine"/> 은 **스탯 변화가 하나도 없을 때** 대신 채우는 줄
/// (WL-813y — 원본은 레벨업으로 스탯을 안 올리고 포인트만 준다 · 813k 보고 ①).
/// </summary>
public string Show(int newLevel, WLStatDelta[] deltas, string fallbackLine)
{
var s = WLCombatTextSettings.Instance;
if (s == null) return "설정 없음";
@ -187,6 +254,14 @@ namespace WL.UI
sb.Append(" / ").Append(_rows[i].text);
shown++;
}
UsedPointLine = false;
if (shown == 0 && !string.IsNullOrEmpty(fallbackLine) && _rows.Count > 0)
{
_rows[0].text = fallbackLine;
sb.Append(" / ").Append(fallbackLine);
shown = 1;
UsedPointLine = true;
}
LastRowCount = shown;
LastText = sb.ToString();
@ -262,6 +337,34 @@ namespace WL.UI
return "dispatch LevelUp level=" + newLevel + " · 구독자=" + CombatEvents.LevelUp.Count;
}
/// <summary>
/// 합성 813k 레벨업 — GrowthEvents 의 **실제 Dispatch 경로**로 쏜다(구독 배선까지 검증).
/// statDelta 가 0 이면 원본 기본값(prev == new) 상황 = "포인트" 줄로 떨어지는지 확인한다.
/// </summary>
public static string RaiseFakeGrowthLevelUp(int newLevel, int statPointTotal, double statDelta)
{
var e = new PlayerLevelUpEvent
{
data = null,
pc = null,
prevLevel = newLevel - 1,
newLevel = newLevel,
levelsGained = 1,
statPointTotal = statPointTotal,
statAKind = eStat.MaxHP, statBKind = eStat.MaxMP, statCKind = eStat.ATK,
prevStatA = 1000d, newStatA = 1000d + statDelta,
prevStatB = 200d, newStatB = 200d + statDelta,
prevStatC = 50d, newStatC = 50d + statDelta,
hpBefore = 370d, maxHp = 1000d, mpBefore = 120d, maxMp = 200d,
position = Vector3.zero,
time = Time.unscaledTime,
frame = Time.frameCount,
};
GrowthEvents.LevelUp.Dispatch(in e);
return "dispatch GrowthEvents.LevelUp level=" + newLevel + " point=" + statPointTotal +
" statDelta=" + statDelta + " · 구독자=" + GrowthEvents.LevelUp.Count;
}
/// <summary>합성 스탯 델타로 직접 표시(813k 페이로드가 오면 이 모양이 된다).</summary>
public string ShowFake(int newLevel, string[] names, double[] prev, double[] next)
{
@ -274,9 +377,13 @@ namespace WL.UI
public string Dump()
{
var sb = new StringBuilder();
sb.AppendLine("StatPopup subscribed=" + _subscribed + " levelUpSeen=" + LevelUpSeen +
sb.AppendLine("StatPopup subscribed=" + _subscribed + " 축=" + (_usingGrowth ? "813k GrowthEvents" : "811b CombatEvents") +
"(구독자 growth=" + GrowthEvents.LevelUp.Count + " core=" + CombatEvents.LevelUp.Count + ")" +
" levelUpSeen=" + LevelUpSeen +
" lastLevel=" + LastLevel + " rows=" + LastRowCount +
" 포인트줄=" + UsedPointLine + " statPoint=" + LastStatPointTotal + " levelsGained=" + LastLevelsGained +
" alpha=" + (group != null ? group.alpha.ToString("F2") : "-"));
sb.AppendLine(" edge = " + _lastEdge);
sb.AppendLine(" title=\"" + (titleLabel != null ? titleLabel.text : "") + "\" pos=" +
(box != null ? box.anchoredPosition.ToString() : "-") +
" size=" + (box != null ? box.sizeDelta.ToString() : "-") +

View File

@ -54,6 +54,10 @@ namespace WL.UI
public float LastMinGapPx { get; private set; }
public int LastActiveSlots { get; private set; }
public int LastReserveSlots { get; private set; }
/// <summary>전투 액션 버튼(스킬 4 + 공격 + 물약)만의 도달 반경 — 자동전투 토글 제외(WL-813y).</summary>
public float LastActionReachPx { get; private set; }
/// <summary>마지막 배치 때의 하단 메뉴 상태(true = 메뉴 보임 → 패드가 idle 자리로 올라감).</summary>
public bool LastBottomMenuVisible { get; private set; }
private void Awake() { _rt = GetComponent<RectTransform>(); }
private void OnEnable() { Apply(); }
@ -105,9 +109,13 @@ namespace WL.UI
LastActiveSlots = active;
LastReserveSlots = Mathf.Max(0, total - active);
float maxReach = 0f;
float maxReach = 0f, actionReach = 0f;
var centers = new Vector2[total];
// WL-813y — 하단 메뉴가 보이는 동안만 패드를 위로 올린다(전투 중에는 모서리 원점).
bool menuVisible = HudCombatVisibility.BottomMenuVisible;
LastBottomMenuVisible = menuVisible;
for (int i = 0; i < total; i++)
{
var card = cards[i];
@ -116,12 +124,13 @@ namespace WL.UI
if (crt == null) continue;
bool isSkill = i < active;
Vector2 px = isSkill ? s.FanSlotPx(i) : s.ReserveSlotPx(i - active);
Vector2 px = isSkill ? s.FanSlotPx(i, menuVisible) : s.ReserveSlotPx(i - active, menuVisible);
float dia = isSkill ? s.slotDiameterPx : s.reserveDiameterPx;
PlaceFromCorner(crt, px, dia, u, s.mirrorLeftHanded);
centers[i] = px;
maxReach = Mathf.Max(maxReach, px.magnitude + dia * 0.5f);
if (isSkill) actionReach = Mathf.Max(actionReach, px.magnitude + dia * 0.5f);
// 4 활성 · 나머지는 물약/회피(813j/813r) 예약 자리 = 비활성
if (card.gameObject.activeSelf != isSkill) card.gameObject.SetActive(isSkill);
@ -131,8 +140,10 @@ namespace WL.UI
var attack = FindChild(attackButtonName);
if (attack != null)
{
PlaceFromCorner(attack, s.attackCenterPx, s.attackDiameterPx, u, s.mirrorLeftHanded);
maxReach = Mathf.Max(maxReach, s.attackCenterPx.magnitude + s.attackDiameterPx * 0.5f);
Vector2 apx = s.AttackCenterPxFor(menuVisible);
PlaceFromCorner(attack, apx, s.attackDiameterPx, u, s.mirrorLeftHanded);
maxReach = Mathf.Max(maxReach, apx.magnitude + s.attackDiameterPx * 0.5f);
actionReach = Mathf.Max(actionReach, apx.magnitude + s.attackDiameterPx * 0.5f);
}
var auto = FindChild(autoButtonName);
if (auto != null)
@ -146,13 +157,28 @@ namespace WL.UI
// 813tj 물약 버튼 — 예약 슬롯 자리에 있으므로 미러·해상도가 바뀌면 같이 다시 앉힌다.
var potion = GetComponentInChildren<PotionButton>(true);
if (potion != null) potion.ApplyLayout();
if (potion != null)
{
potion.ApplyLayout();
Vector2 ppx = s.ReserveSlotPx(Mathf.Max(0, PotionSlotIndex()), menuVisible);
actionReach = Mathf.Max(actionReach, ppx.magnitude + s.reserveDiameterPx * 0.5f);
}
LastActionReachPx = actionReach;
return "applied slots=" + active + "/" + total + " unitsPerPx=" + u.ToString("F4") +
" maxReachPx=" + maxReach.ToString("F1") + " minGapPx=" + LastMinGapPx.ToString("F1") +
" maxReachPx=" + maxReach.ToString("F1") +
" actionReachPx=" + actionReach.ToString("F1") + "(스킬+공격+물약 · 자동토글 제외)" +
" minGapPx=" + LastMinGapPx.ToString("F1") +
" menuVisible=" + menuVisible + " fanOrigin=" + s.FanOriginPxFor(menuVisible) +
" mirror=" + s.mirrorLeftHanded;
}
private static int PotionSlotIndex()
{
var su = WLSurvivalUiSettings.Instance;
return su != null ? su.potionReserveSlotIndex : 0;
}
/// <summary>
/// 모서리(우하단 · 미러면 좌하단) 기준 (dx, dy) px 로 배치한다.
/// 813tj 물약 버튼(PotionButton)이 예약 슬롯 자리에 앉을 때 **같은 산식**을 쓰도록 public 이다.
@ -225,6 +251,10 @@ namespace WL.UI
" activeSlots=" + LastActiveSlots + "/" + (LastActiveSlots + LastReserveSlots) +
" maxReachPx=" + LastMaxReachPx.ToString("F1") +
" (목표 " + (s != null ? s.thumbReachRadiusPx.ToString("F0") : "?") + ")" +
" actionReachPx=" + LastActionReachPx.ToString("F1") + "(자동토글 제외)" +
" menuVisible=" + LastBottomMenuVisible +
" fanOrigin=" + (s != null ? s.FanOriginPxFor(LastBottomMenuVisible).ToString() : "?") +
" attackCenter=" + (s != null ? s.AttackCenterPxFor(LastBottomMenuVisible).ToString() : "?") +
" minGapPx=" + LastMinGapPx.ToString("F1"));
sb.AppendLine(" root " + RectLine(_rt != null ? _rt : GetComponent<RectTransform>()) +
" safeAreaFitter=" + (GetComponent<SafeAreaFitter>() != null));

View File

@ -0,0 +1,204 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSurvivalUiBridge.cs — 813tj 가 열어 둔 6줄을 813i API 에 연결한다 (WL-813y §1-1 · #813)
//
// 813tj 완료보고 §4 ②:
// "813i 병합 시 PotionButton.CountProvider/CooldownRemainProvider/CooldownTotalProvider/UseHandler 4줄 +
// ReviveDialog.NotifyDeath()/ReviveRequested 2줄만 연결하면 끝(UI 수정 0)"
//
// ■ 왜 Gameplay 가 아니라 여기인가
// 813i(`Assets/WL/Combat/Survival/**`)는 **Gameplay 소유**라 이 세션이 손대지 못한다(발주서 §2 금지).
// UI 쪽에서 813i 의 **public API 만 호출**하는 얇은 브리지를 두면 소유 경계를 넘지 않고 6줄이 붙는다.
//
// ■ 연결 6줄 (813i 완료보고 §4 · 전부 `WL.Combat.Survival`)
// ① PotionButton.CountProvider ← PotionUse.Remaining
// ② PotionButton.CooldownRemainProvider ← PotionUse.CooldownRemaining
// ③ PotionButton.CooldownTotalProvider ← PotionUse.CooldownTotal
// ④ PotionButton.UseHandler ← PotionUse.TryUse() == PotionResult.Used
// ⑤ ReviveDialog.NotifyDeath() ← DeathFlow.Died / DeathFlow.ReviveRequested 구독
// ⑥ ReviveDialog.ReviveRequested ← DeathFlow.Revive()
//
// ■ 스위치 = 813i 소유 `WLSurvivalSettings.Enabled`(에셋 `enabled` · **Lead 가 병합 시 켠다**)
// 꺼져 있으면 물약 표시는 813tj 의 표시용 기본값으로 떨어지고, 사망/부활 이벤트는 애초에 안 온다
// → 병합 전후 어느 쪽이든 화면이 깨지지 않는다(C8).
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using UnityEngine;
using WL.Combat.Survival;
namespace WL.UI
{
public static class WLSurvivalUiBridge
{
public static bool Installed { get; private set; }
// 진단(프로브가 읽는다 · 실측만)
public static int DiedSeen, ReviveRequestedSeen, RevivedSeen, PotionChangedSeen;
public static int ReviveCalls, PotionUseCalls, PotionUseOk;
public static string LastEvent = "";
public static float LastEventTime;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Boot() { Install(); }
/// <summary>6줄을 연결한다(중복 호출 안전). 프로브도 이 경로를 쓴다.</summary>
public static string Install()
{
if (Installed) return "이미 연결됨";
// ── 물약 4줄 ──────────────────────────────────────────────────────
// 스위치가 꺼져 있으면 813tj 의 표시용 기본값으로 떨어진다(연결 전 화면과 동일).
PotionButton.CountProvider = ReadCount;
PotionButton.CooldownRemainProvider = ReadCooldownRemain;
PotionButton.CooldownTotalProvider = ReadCooldownTotal;
PotionButton.UseHandler = Use;
// ── 부활 2줄 ──────────────────────────────────────────────────────
ReviveDialog.ReviveRequested = RequestRevive;
DeathFlow.Died.Add(OnDied);
DeathFlow.ReviveRequested.Add(OnReviveRequested);
DeathFlow.Revived.Add(OnRevived);
PotionUse.Changed.Add(OnPotionChanged);
Installed = true;
return "연결 6줄 완료 · survivalEnabled=" + WLSurvivalSettings.Enabled;
}
/// <summary>연결을 끊는다(프로브 종료 · 813tj 원래 상태로 복귀).</summary>
public static string Uninstall()
{
if (!Installed) return "연결 안 돼 있음";
PotionButton.ClearProvider();
ReviveDialog.ReviveRequested = null;
DeathFlow.Died.Remove(OnDied);
DeathFlow.ReviveRequested.Remove(OnReviveRequested);
DeathFlow.Revived.Remove(OnRevived);
PotionUse.Changed.Remove(OnPotionChanged);
Installed = false;
return "연결 해제(813tj 미연결 상태로 복귀)";
}
// ── 물약 (813i PotionUse) ─────────────────────────────────────────────
private static int ReadCount()
{
if (!WLSurvivalSettings.Enabled) return FallbackCount();
return PotionUse.Remaining;
}
private static float ReadCooldownRemain()
{
return WLSurvivalSettings.Enabled ? PotionUse.CooldownRemaining : 0f;
}
private static float ReadCooldownTotal()
{
if (!WLSurvivalSettings.Enabled) return FallbackCooldown();
float t = PotionUse.CooldownTotal;
return t > 0f ? t : FallbackCooldown();
}
private static bool Use()
{
PotionUseCalls++;
if (!WLSurvivalSettings.Enabled) { Note("potion:disabled"); return false; }
var r = PotionUse.TryUse();
bool ok = r == PotionResult.Used;
if (ok) PotionUseOk++;
Note("potion:" + r);
return ok;
}
private static int FallbackCount()
{
var s = WLSurvivalUiSettings.Instance;
return s != null ? s.potionFallbackCount : 0;
}
private static float FallbackCooldown()
{
var s = WLSurvivalUiSettings.Instance;
return s != null ? s.potionFallbackCooldownSeconds : 0f;
}
private static void OnPotionChanged(in PotionEvent e)
{
PotionChangedSeen++;
Note("changed:" + e.change + "(" + e.remaining + "/" + e.max + ")");
}
// ── 사망 · 부활 (813i DeathFlow) ──────────────────────────────────────
/// <summary>사망 = 팝업 예약(813tj 는 자체 지연으로 사망 연출 시간을 기다린다).</summary>
private static void OnDied(in DeathEvent e)
{
DiedSeen++;
Note("died#" + e.deathCount + " display=" + e.displaySeconds.ToString("F2") + "s · " + ReviveDialog.NotifyDeath());
}
/// <summary>
/// 813i 가 사망 연출을 끝내고 부활 대기에 들어간 시점 — 지연 없이 지금 연다.
/// **이 구독자가 있어야** 813i 의 `autoReviveWhenNoListener`(구독 0 이면 자동 부활)가 꺼진다.
/// </summary>
private static void OnReviveRequested(in ReviveRequestEvent e)
{
ReviveRequestedSeen++;
Note("reviveRequested#" + e.deathCount + " worldHeld=" + e.worldHeld + " · " + ReviveDialog.NotifyReviveRequested());
}
private static void OnRevived(in RevivedEvent e)
{
RevivedSeen++;
Note("revived#" + e.deathCount + " external=" + e.external + " auto=" + e.auto + " · " + ReviveDialog.NotifyRevived());
}
/// <summary>팝업 "존 시작점에서 부활" 버튼 → 813i.</summary>
private static void RequestRevive()
{
ReviveCalls++;
bool ok = false;
if (WLSurvivalSettings.Enabled) ok = DeathFlow.Revive();
Note("revive() → " + ok);
}
private static void Note(string s)
{
LastEvent = s;
LastEventTime = Time.unscaledTime;
}
// ── 진단 ──────────────────────────────────────────────────────────────
public static void ResetDiagnostics()
{
DiedSeen = ReviveRequestedSeen = RevivedSeen = PotionChangedSeen = 0;
ReviveCalls = PotionUseCalls = PotionUseOk = 0;
LastEvent = ""; LastEventTime = 0f;
}
public static string Dump()
{
var sb = new StringBuilder();
sb.AppendLine("[WLSurvivalUiBridge] installed=" + Installed +
" survivalEnabled=" + WLSurvivalSettings.Enabled +
" · died=" + DiedSeen + " reviveReq=" + ReviveRequestedSeen + " revived=" + RevivedSeen +
" potionChanged=" + PotionChangedSeen +
" reviveCalls=" + ReviveCalls + " potionUse=" + PotionUseOk + "/" + PotionUseCalls +
" last=\"" + LastEvent + "\"");
sb.AppendLine(" 연결 6줄 = count:" + (PotionButton.CountProvider != null) +
" cdRemain:" + (PotionButton.CooldownRemainProvider != null) +
" cdTotal:" + (PotionButton.CooldownTotalProvider != null) +
" use:" + (PotionButton.UseHandler != null) +
" reviveReq:" + (ReviveDialog.ReviveRequested != null) +
" deathFlow구독(Died/ReviveRequested/Revived)=" +
DeathFlow.Died.Count + "/" + DeathFlow.ReviveRequested.Count + "/" + DeathFlow.Revived.Count);
sb.AppendLine(" 813i 현재값 = remaining " + PotionUse.Remaining + "/" + PotionUse.Max +
" cd " + PotionUse.CooldownRemaining.ToString("F2") + "/" + PotionUse.CooldownTotal.ToString("F2") + "s" +
" ready=" + PotionUse.IsReady +
" · DeathFlow state=" + DeathFlow.State + " deaths=" + DeathFlow.DeathCount +
" revives=" + DeathFlow.ReviveCount + " auto=" + DeathFlow.AutoReviveCount);
sb.AppendLine(" UI 가 읽는 값 = count " + ReadCount() + " cdRemain " + ReadCooldownRemain().ToString("F2") +
"s cdTotal " + ReadCooldownTotal().ToString("F2") + "s");
return sb.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3da24e17a6c2d0b42acbced8fd585620

View File

@ -8,6 +8,7 @@
// 값은 하나도 들고 있지 않다(전부 WLCombatTextSettings). 여기 있는 숫자는 Unity 기본값(앵커 0/1)뿐이다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using TMPro;
using UnityEngine;
@ -82,5 +83,143 @@ namespace WL.UI
float k = Mathf.Clamp01(elapsed / punchSeconds);
return Mathf.Lerp(punchScale, 1f, k * k);
}
// ── 아웃라인 · 그림자 (WL-813y · Q3 결함 D-6) ─────────────────────────
// uGUI `Outline`/`Shadow`(BaseMeshEffect)는 **TextMeshPro 에 적용되지 않는다** —
// TMP_Text 는 IMeshModifier 를 부르지 않고 자체 메시를 만든다(실측 근거는 완료보고).
// 그래서 TMP 의 정식 경로인 **머티리얼 아웃라인(_OutlineWidth/_OutlineColor)** +
// **언더레이 그림자(UNDERLAY_ON · _UnderlayOffsetX/Y · _UnderlaySoftness)** 를 쓴다. 포스트 0.
// 라벨마다 인스턴스 머티리얼을 만들면 배칭이 깨지므로, (원본 머티리얼 + 값) 조합마다
// **변형 머티리얼 1장**을 캐시해 `fontSharedMaterial` 로 공유한다.
private static readonly Dictionary<string, Material> s_edgeMats = new Dictionary<string, Material>();
private static readonly Dictionary<Material, Material> s_edgeOrigin = new Dictionary<Material, Material>();
/// <summary>지금까지 만든 아웃라인 변형 머티리얼 수(진단).</summary>
internal static int EdgeMaterialCount { get { return s_edgeMats.Count; } }
/// <summary>TMP 라벨에 아웃라인 + 언더레이 그림자를 입힌다. 적용 결과 문자열(실측)을 돌려준다.</summary>
internal static string ApplyEdge(TMP_Text label, WLTextEdge edge)
{
if (label == null) return "label=null";
Material src = label.fontSharedMaterial;
if (src == null) return "sharedMaterial=null";
Material origin;
if (s_edgeOrigin.TryGetValue(src, out origin)) src = origin; // 이미 우리 변형이면 원본으로 되돌아가서 다시 만든다
if (!edge.enabled || (edge.outlineWidth <= 0f && edge.shadowColor.a <= 0f))
{
if (!ReferenceEquals(label.fontSharedMaterial, src)) { label.fontSharedMaterial = src; label.UpdateMeshPadding(); }
return "edge off → 원본 머티리얼(" + src.name + ")";
}
string key = src.GetInstanceID() + "|" + edge.Key();
Material mat;
if (!s_edgeMats.TryGetValue(key, out mat) || mat == null)
{
mat = new Material(src);
mat.name = src.name + " (WL813y Edge)";
mat.hideFlags = HideFlags.DontSave;
Write(mat, edge);
s_edgeMats[key] = mat;
s_edgeOrigin[mat] = src;
}
if (!ReferenceEquals(label.fontSharedMaterial, mat))
{
label.fontSharedMaterial = mat;
label.UpdateMeshPadding(); // 아웃라인이 글리프 밖으로 나가므로 패딩을 다시 잡는다
}
return Describe(mat);
}
private static void Write(Material m, WLTextEdge e)
{
if (m.HasProperty(ShaderUtilities.ID_OutlineWidth)) m.SetFloat(ShaderUtilities.ID_OutlineWidth, Mathf.Clamp01(e.outlineWidth));
if (m.HasProperty(ShaderUtilities.ID_OutlineColor)) m.SetColor(ShaderUtilities.ID_OutlineColor, e.outlineColor);
if (m.HasProperty(ShaderUtilities.ID_FaceDilate)) m.SetFloat(ShaderUtilities.ID_FaceDilate, Mathf.Clamp(e.faceDilate, -1f, 1f));
bool shadow = e.shadowColor.a > 0f && (Mathf.Abs(e.shadowOffset.x) > 0f || Mathf.Abs(e.shadowOffset.y) > 0f || e.shadowSoftness > 0f);
if (m.HasProperty(ShaderUtilities.ID_UnderlayColor))
{
m.SetColor(ShaderUtilities.ID_UnderlayColor, shadow ? e.shadowColor : new Color(0f, 0f, 0f, 0f));
if (m.HasProperty(ShaderUtilities.ID_UnderlayOffsetX)) m.SetFloat(ShaderUtilities.ID_UnderlayOffsetX, e.shadowOffset.x);
if (m.HasProperty(ShaderUtilities.ID_UnderlayOffsetY)) m.SetFloat(ShaderUtilities.ID_UnderlayOffsetY, e.shadowOffset.y);
if (m.HasProperty(ShaderUtilities.ID_UnderlaySoftness)) m.SetFloat(ShaderUtilities.ID_UnderlaySoftness, Mathf.Clamp01(e.shadowSoftness));
if (m.HasProperty(ShaderUtilities.ID_UnderlayDilate)) m.SetFloat(ShaderUtilities.ID_UnderlayDilate, 0f);
if (shadow) m.EnableKeyword("UNDERLAY_ON"); else m.DisableKeyword("UNDERLAY_ON");
}
if (m.HasProperty(ShaderUtilities.ID_OutlineWidth) && e.outlineWidth > 0f) m.EnableKeyword("OUTLINE_ON");
}
/// <summary>머티리얼에 실제로 들어간 값(실측 덤프용).</summary>
internal static string Describe(Material m)
{
if (m == null) return "(머티리얼 없음)";
var sb = new System.Text.StringBuilder();
sb.Append(m.name).Append(" shader=").Append(m.shader != null ? m.shader.name : "-");
sb.Append(" outlineW=").Append(m.HasProperty(ShaderUtilities.ID_OutlineWidth) ? m.GetFloat(ShaderUtilities.ID_OutlineWidth).ToString("F3") : "없음");
sb.Append(" outlineC=").Append(m.HasProperty(ShaderUtilities.ID_OutlineColor) ? ColorUtility.ToHtmlStringRGBA(m.GetColor(ShaderUtilities.ID_OutlineColor)) : "없음");
if (m.HasProperty(ShaderUtilities.ID_UnderlayColor))
{
sb.Append(" underlay=").Append(ColorUtility.ToHtmlStringRGBA(m.GetColor(ShaderUtilities.ID_UnderlayColor)));
sb.Append(" off(").Append(m.GetFloat(ShaderUtilities.ID_UnderlayOffsetX).ToString("F2")).Append(',')
.Append(m.GetFloat(ShaderUtilities.ID_UnderlayOffsetY).ToString("F2")).Append(')');
sb.Append(" soft=").Append(m.GetFloat(ShaderUtilities.ID_UnderlaySoftness).ToString("F2"));
sb.Append(" UNDERLAY_ON=").Append(m.IsKeywordEnabled("UNDERLAY_ON"));
}
return sb.ToString();
}
/// <summary>라벨을 원본 머티리얼로 되돌린다(프로브·롤백).</summary>
internal static string ClearEdge(TMP_Text label)
{
if (label == null) return "label=null";
Material origin;
if (label.fontSharedMaterial != null && s_edgeOrigin.TryGetValue(label.fontSharedMaterial, out origin))
{
label.fontSharedMaterial = origin;
label.UpdateMeshPadding();
return "원복 → " + origin.name;
}
return "변형 아님 — 그대로";
}
}
/// <summary>
/// 텍스트 테두리·그림자 값 묶음 (WL-813y · C8/C45 — 코드 상수 0 · 전부 SO).
/// TMP 머티리얼 규격 그대로다: outlineWidth 0~1 · shadowOffset 은 폰트 단위(±1) · softness 0~1.
/// </summary>
[System.Serializable]
public struct WLTextEdge
{
[Tooltip("이 텍스트에 아웃라인/그림자를 입힐지")]
public bool enabled;
[Tooltip("아웃라인 두께 (TMP _OutlineWidth · 0~1 · 0.2~0.3 이 육안으로 또렷)")]
[Range(0f, 1f)] public float outlineWidth;
[Tooltip("아웃라인 색")]
public Color outlineColor;
[Tooltip("그림자(언더레이) 오프셋 — 폰트 단위(±1). 예 (0.6, -0.6) = 우하단")]
public Vector2 shadowOffset;
[Tooltip("그림자 색 — 알파 0 이면 그림자 없음")]
public Color shadowColor;
[Tooltip("그림자 번짐 (TMP _UnderlaySoftness · 0~1)")]
[Range(0f, 1f)] public float shadowSoftness;
[Tooltip("글자 두께 보정 (TMP _FaceDilate · -1~1). 아웃라인이 글자를 먹으면 살짝 올린다")]
[Range(-1f, 1f)] public float faceDilate;
internal string Key()
{
return (enabled ? "1" : "0") + outlineWidth.ToString("F3") + outlineColor + shadowOffset + shadowColor +
shadowSoftness.ToString("F3") + faceDilate.ToString("F3");
}
public override string ToString()
{
return "on=" + enabled + " w=" + outlineWidth.ToString("F2") +
" oc=#" + ColorUtility.ToHtmlStringRGBA(outlineColor) +
" sh" + shadowOffset + " sc=#" + ColorUtility.ToHtmlStringRGBA(shadowColor) +
" soft=" + shadowSoftness.ToString("F2") + " dilate=" + faceDilate.ToString("F2");
}
}
}

View File

@ -14,6 +14,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLBossUiSettings
bannerEnabled: 1
retriggerGuardSeconds: 3
bannerTriggerExisting: 1
bannerSeconds: 1.2
bannerFadeInSeconds: 0.15
bannerFadeOutSeconds: 0.25

View File

@ -52,14 +52,14 @@ MonoBehaviour:
killChainFormat: '{0}!'
killChainSubFormat: <size=45%>{0} KILLS</size>
lootToastEnabled: 1
lootToastSeconds: 1.2
lootToastSeconds: 2
lootToastLines: 3
lootToastMergeSeconds: 1
lootToastRightMarginPx: 40
lootToastCenterOffsetPx: 120
lootToastLineHeightPx: 54
lootToastFontPx: 40
lootToastWidthPx: 520
lootToastCenterOffsetPx: -120
lootToastLineHeightPx: 68
lootToastFontPx: 52
lootToastWidthPx: 560
lootToastFadeStartRatio: 0.65
lootToastSlideInPx: 60
lootToastSlideSeconds: 0.14
@ -85,3 +85,45 @@ MonoBehaviour:
statPopupRowFormat: '{0} +{1}'
statPopupTitleColor: {r: 1, g: 0.9, b: 0.45, a: 1}
statPopupRowColor: {r: 0.55, g: 0.95, b: 0.62, a: 1}
statPopupUseGrowthEvents: 1
statPopupPointFormat: "+5 \uD3EC\uC778\uD2B8 <size=70%>(\uBCF4\uC720 {0})</size>"
statPopupMergeMultiLevel: 1
textEdgeEnabled: 1
killChainEdge:
enabled: 1
outlineWidth: 0.3
outlineColor: {r: 0.05, g: 0.03, b: 0.02, a: 1}
shadowOffset: {x: 0.55, y: -0.55}
shadowColor: {r: 0, g: 0, b: 0, a: 0.8}
shadowSoftness: 0.25
faceDilate: 0.1
statPopupEdge:
enabled: 1
outlineWidth: 0.26
outlineColor: {r: 0.05, g: 0.03, b: 0.02, a: 1}
shadowOffset: {x: 0.45, y: -0.45}
shadowColor: {r: 0, g: 0, b: 0, a: 0.75}
shadowSoftness: 0.2
faceDilate: 0.08
damageEdge:
enabled: 1
outlineWidth: 0.22
outlineColor: {r: 0.03, g: 0.02, b: 0.02, a: 1}
shadowOffset: {x: 0.35, y: -0.35}
shadowColor: {r: 0, g: 0, b: 0, a: 0.7}
shadowSoftness: 0.15
faceDilate: 0.05
lootToastEdge:
enabled: 1
outlineWidth: 0.24
outlineColor: {r: 0.03, g: 0.02, b: 0.02, a: 1}
shadowOffset: {x: 0.4, y: -0.4}
shadowColor: {r: 0, g: 0, b: 0, a: 0.7}
shadowSoftness: 0.18
faceDilate: 0.06
lootToastPanelEnabled: 1
lootToastPanelColor: {r: 0.03, g: 0.03, b: 0.05, a: 0.68}
lootToastPanelPadXPx: 18
lootToastPanelPadYPx: 6
lootToastMinBrightColor: {r: 0.95, g: 0.96, b: 0.98, a: 1}
lootToastMinBrightness: 0.72

View File

@ -20,7 +20,7 @@ MonoBehaviour:
slotDiameterPx: 140
slotMinGapPx: 16
thumbReachRadiusPx: 320
fanOriginPx: {x: 0, y: 260}
fanOriginPx: {x: 0, y: 0}
fanRadiusPx: 420
fanStartAngleDeg: 12.5
fanStepAngleDeg: 22
@ -28,7 +28,7 @@ MonoBehaviour:
reserveRadiusPx: 270
reserveStartAngleDeg: 25
reserveStepAngleDeg: 40
attackCenterPx: {x: 100, y: 360}
attackCenterPx: {x: 100, y: 100}
attackDiameterPx: 132
autoButtonCenterPx: {x: 56, y: 545}
autoButtonDiameterPx: 34.3
@ -48,3 +48,21 @@ MonoBehaviour:
bossBarBgColor: {r: 0.05, g: 0.05, b: 0.06, a: 0.78}
bossBarTickColor: {r: 1, g: 1, b: 1, a: 0.85}
bossBarNameColor: {r: 1, g: 0.92, b: 0.6, a: 1}
bossBarBindExisting: 1
combatHideBottomMenu: 1
bottomMenuPath: IngameUIs/WL_HUD/HUD_BottomMenu
bottomMenuUseCanvasGroup: 1
bottomMenuFadeSeconds: 0.18
combatExitSeconds: 6
combatEnterDelaySeconds: 0
combatUseTargetAsSignal: 1
bottomMenuRestoreButton: 1
restoreButtonCenterPx: {x: -380, y: 62}
restoreButtonDiameterPx: 88
restoreButtonLabel: "\u2261"
restoreButtonFontPx: 46
restoreButtonBgColor: {r: 0.06, g: 0.07, b: 0.1, a: 0.72}
restoreButtonLabelColor: {r: 0.96, g: 0.94, b: 0.82, a: 1}
restoreHoldSeconds: 8
idleFanOriginPx: {x: 0, y: 260}
idleAttackCenterPx: {x: 100, y: 360}

View File

@ -54,6 +54,9 @@ namespace WL.UI
[Tooltip("같은 보스가 다시 Spawned 를 내도 이 시간 안이면 배너를 다시 띄우지 않는다(중복 방지)")]
public float retriggerGuardSeconds = 3f;
[Tooltip("배너가 켜질 때 이미 살아 있는 보스가 있으면 즉시 배너를 띄운다(맵 로드 시 스폰 = Q3 결함 D-3 · WL-813y). 끄면 Spawned/BossPhase 이벤트만 본다.")]
public bool bannerTriggerExisting = true;
// ── 배너 ──────────────────────────────────────────────────────────────
[Header("배너 (기준서 §D-1 813t · 1.2 s)")]
[Tooltip("배너 총 표시 시간 — 기준서 1.2")]

View File

@ -269,6 +269,88 @@ namespace WL.UI
[Tooltip("스탯 줄 색.")]
public Color statPopupRowColor = new Color(0.55f, 0.95f, 0.62f, 1f);
[Tooltip("813k GrowthEvents.LevelUp(스탯 3종 + 잔여 포인트)을 구독한다. 끄면 811b 코어 LevelUp(레벨만)만 본다.")]
public bool statPopupUseGrowthEvents = true;
[Tooltip("스탯 변화가 0 일 때(원본은 레벨업으로 스탯을 안 올린다 · 813k ①) 대신 보여 줄 줄 — {0} = 잔여 스탯 포인트. 빈 문자열이면 표시 안 함.")]
public string statPopupPointFormat = "+5 포인트 <size=70%>(보유 {0})</size>";
[Tooltip("한 번의 Add_Exp 에서 여러 레벨이 오를 때(levelsGained ≥ 2) 마지막 레벨만 팝업한다.")]
public bool statPopupMergeMultiLevel = true;
[Header("⑤ 텍스트 아웃라인 · 그림자 (Q3 결함 D-6 · 밝은 배경 대비 · 포스트 0)")]
[Tooltip("끄면 아래 4종이 전부 원본 머티리얼로 돌아간다(C8 롤백).")]
public bool textEdgeEnabled = true;
[Tooltip("연쇄 처치 문구(KillChainText) 테두리.")]
public WLTextEdge 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,
};
[Tooltip("레벨업 스탯 팝업(StatPopup) 테두리.")]
public WLTextEdge 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,
};
[Tooltip("데미지 숫자(HUDDMGUI) 테두리. 숫자가 작아 두께는 낮게.")]
public WLTextEdge 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,
};
[Tooltip("전리품 토스트(LootToast) 테두리.")]
public WLTextEdge 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,
};
[Header("⑥ 전리품 토스트 가시성 (Q3 결함 D-6 ② · 15회 발생 · 육안 실패)")]
[Tooltip("줄 뒤에 반투명 패널을 깐다(밝은 배경 대비 확보 · Image 1장/줄).")]
public bool lootToastPanelEnabled = true;
[Tooltip("패널 색(알파 포함).")]
public Color lootToastPanelColor = new Color(0.03f, 0.03f, 0.05f, 0.68f);
[Tooltip("패널이 글자 좌우로 넘치는 여백(px).")]
public float lootToastPanelPadXPx = 18f;
[Tooltip("패널이 글자 위아래로 넘치는 여백(px).")]
public float lootToastPanelPadYPx = 6f;
[Tooltip("등급 1(회색) 처럼 어두운 등급 색을 이 색으로 대체한다(알파 0 이면 원래 등급 색 유지).")]
public Color lootToastMinBrightColor = new Color(0.95f, 0.96f, 0.98f, 1f);
[Tooltip("등급 색의 밝기(V)가 이 값보다 어두우면 위 색으로 바꾼다(0~1 · 0 이면 대체 안 함).")]
[Range(0f, 1f)] public float lootToastMinBrightness = 0.72f;
// ── 파생값 헬퍼 (코드 상수 0) ──────────────────────────────────────────
/// <summary>
@ -332,5 +414,29 @@ namespace WL.UI
/// <summary>등급의 리치텍스트 색 태그(원본 팔레트 그대로 · 기준서 §B 요소 4 「보유 팔레트 그대로」).</summary>
public string GradeColorTag(int grade) { return MyValue.Get_GradeColor(grade); }
/// <summary>
/// 밝은 배경에서도 읽히도록 보정한 등급 색 태그 (WL-813y · Q3 결함 D-6 ②).
/// 등급 1 은 원본 팔레트가 `#a6a6a7`(회색 · V≈0.65) 이라 모래 배경에서 사라진다 —
/// 밝기 V 가 <see cref="lootToastMinBrightness"/> 미만이면 색상(H)·채도(S)를 유지한 채 V 를 끌어올린다.
/// 값이 0 이거나 대체 색 알파가 0 이면 **원본 팔레트 그대로**(C8 롤백).
/// </summary>
public string GradeColorTagBright(int grade)
{
if (lootToastMinBrightness <= 0f || lootToastMinBrightColor.a <= 0f) return GradeColorTag(grade);
Color c = GradeColor(grade);
float h, s, v;
Color.RGBToHSV(c, out h, out s, out v);
if (v >= lootToastMinBrightness) return GradeColorTag(grade);
Color lift = s <= 0.05f ? lootToastMinBrightColor : Color.HSVToRGB(h, s, lootToastMinBrightness);
return "<color=#" + ColorUtility.ToHtmlStringRGB(lift) + ">";
}
/// <summary>전체 스위치(textEdgeEdge)를 반영한 테두리 값. 꺼져 있으면 enabled=false 로 준다.</summary>
public WLTextEdge Edge(WLTextEdge e)
{
if (!textEdgeEnabled) e.enabled = false;
return e;
}
}
}

View File

@ -143,6 +143,62 @@ namespace WL.UI
[Tooltip("이름 라벨 색")]
public Color bossBarNameColor = new Color(1f, 0.92f, 0.6f, 1f);
[Tooltip("바가 켜질 때 이미 살아 있는 보스가 있으면 즉시 물린다(813d 게이트 이전 스폰 = Q3 결함 D-3). 끄면 Spawned 이벤트만 본다.")]
public bool bossBarBindExisting = true;
[Header("전투 중 하단 메뉴 숨김 · 패드 원점 복귀 (WL-813y · Q3 결함 D-5)")]
[Tooltip("끄면 하단 메뉴를 건드리지 않고 패드도 항상 idle 원점(아래 값)을 쓴다 — 813c+Lead 핫픽스 상태로 복귀(C8 롤백).")]
public bool combatHideBottomMenu = true;
[Tooltip("숨길 하단 메뉴 노드의 루트 기준 경로(813y 실측 = IngameUIs/WL_HUD/HUD_BottomMenu).")]
public string bottomMenuPath = "IngameUIs/WL_HUD/HUD_BottomMenu";
[Tooltip("SetActive 대신 CanvasGroup 으로 숨긴다(하위 스크립트의 Update 가 계속 돌아야 하는 경우 · #796 선례).")]
public bool bottomMenuUseCanvasGroup = true;
[Tooltip("숨김/복귀 페이드 시간(초 · unscaled). 0 이면 즉시.")]
public float bottomMenuFadeSeconds = 0.18f;
[Tooltip("마지막 전투 신호(적 인지 · 피격 · 공격 · 스킬 · 처치) 뒤 이 시간이 지나면 메뉴가 돌아온다.")]
public float combatExitSeconds = 6f;
[Tooltip("전투 신호가 들어오고 이 시간이 지나야 숨긴다(0 = 즉시).")]
public float combatEnterDelaySeconds = 0f;
[Tooltip("현재 타깃이 살아 있으면 전투로 본다(적 인지). 끄면 공격/피격/스킬/처치 이벤트만 본다.")]
public bool combatUseTargetAsSignal = true;
[Tooltip("메뉴가 숨은 동안 되돌릴 버튼 1개를 만든다(런타임 생성 · 프리팹 diff 0).")]
public bool bottomMenuRestoreButton = true;
[Tooltip("복귀 버튼 중심 — x = 화면 가로 중앙 기준(양수 = 오른쪽) · y = 화면 아래 기준(px).")]
public Vector2 restoreButtonCenterPx = new Vector2(-380f, 62f);
[Tooltip("복귀 버튼 지름(px).")]
public float restoreButtonDiameterPx = 88f;
[Tooltip("복귀 버튼 글자(TMP · 아트 에셋 의존 0).")]
public string restoreButtonLabel = "≡";
[Tooltip("복귀 버튼 글자 크기(px).")]
public float restoreButtonFontPx = 46f;
[Tooltip("복귀 버튼 배경 색.")]
public Color restoreButtonBgColor = new Color(0.06f, 0.07f, 0.10f, 0.72f);
[Tooltip("복귀 버튼 글자 색.")]
public Color restoreButtonLabelColor = new Color(0.96f, 0.94f, 0.82f, 1f);
[Tooltip("복귀 버튼으로 메뉴를 되돌린 뒤, 이 시간이 지나면 다시 전투 판정을 따른다(초).")]
public float restoreHoldSeconds = 8f;
[Tooltip("하단 메뉴가 **보이는 동안** 쓰는 부채꼴 중심 — 메뉴(상단 y ≈ 163 px)를 피해 위로 올린 813c+Lead 핫픽스 값.")]
public Vector2 idleFanOriginPx = new Vector2(0f, 260f);
[Tooltip("하단 메뉴가 **보이는 동안** 쓰는 공격 버튼 중심.")]
public Vector2 idleAttackCenterPx = new Vector2(100f, 360f);
// ── 유틸 ──────────────────────────────────────────────────────────────
/// <summary>
/// px → 캔버스 유닛 환산 계수.
@ -180,20 +236,43 @@ namespace WL.UI
return 1f;
}
/// <summary>
/// 지금 써야 할 부채꼴 중심. 하단 메뉴가 **보이는 동안**은 메뉴를 피해 올린 자리(idleFanOriginPx),
/// 전투 중(메뉴 숨김)은 모서리 원점(fanOriginPx) — WL-813y §1-6.
/// </summary>
public Vector2 FanOriginPxFor(bool bottomMenuVisible)
{
// C8 롤백: 기능을 끄면 하단 메뉴가 **늘 보이므로** 메뉴를 피한 자리(idle) 를 써야 한다
// = 813c + Lead 핫픽스 상태 그대로. (모서리 원점을 쓰면 메뉴와 다시 겹친다 · Q3 결함 D-5)
if (!combatHideBottomMenu) return idleFanOriginPx;
return bottomMenuVisible ? idleFanOriginPx : fanOriginPx;
}
/// <summary>지금 써야 할 공격 버튼 중심(위와 같은 규칙).</summary>
public Vector2 AttackCenterPxFor(bool bottomMenuVisible)
{
if (!combatHideBottomMenu) return idleAttackCenterPx;
return bottomMenuVisible ? idleAttackCenterPx : attackCenterPx;
}
/// <summary>부채꼴 각도 → 모서리 기준 (dx, dy) px.</summary>
public Vector2 FanSlotPx(int index)
public Vector2 FanSlotPx(int index) { return FanSlotPx(index, HudCombatVisibility.BottomMenuVisible); }
public Vector2 FanSlotPx(int index, bool bottomMenuVisible)
{
float deg = fanStartAngleDeg + fanStepAngleDeg * index;
float rad = deg * Mathf.Deg2Rad;
return fanOriginPx + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * fanRadiusPx;
return FanOriginPxFor(bottomMenuVisible) + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * fanRadiusPx;
}
/// <summary>예약(물약·회피) 슬롯 각도 → 모서리 기준 (dx, dy) px.</summary>
public Vector2 ReserveSlotPx(int index)
public Vector2 ReserveSlotPx(int index) { return ReserveSlotPx(index, HudCombatVisibility.BottomMenuVisible); }
public Vector2 ReserveSlotPx(int index, bool bottomMenuVisible)
{
float deg = reserveStartAngleDeg + reserveStepAngleDeg * index;
float rad = deg * Mathf.Deg2Rad;
return fanOriginPx + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * reserveRadiusPx;
return FanOriginPxFor(bottomMenuVisible) + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * reserveRadiusPx;
}
}
}