[WL-813tj] 보스 배너 · 물약/부활 UI · Safe Area 실효화 (#813)

This commit is contained in:
깃 관리자 2026-09-09 02:13:35 +09:00
parent 6d9d8755c1
commit 10988e50c2
23 changed files with 5337 additions and 30 deletions

View File

@ -0,0 +1,384 @@
// WL-813tj (#813) — ① 설정 에셋 2종 생성 ② NewGameUI Safe Area 실효화(5레이어 재부모화)
// ③ 보스 배너 · 피격 비네트 · 부활 팝업 · 물약 버튼 노드 추가
// 실행:
// unity command run_script --file AgentScripts/WL813tj_Apply.cs --entry WL813tj_Apply.CreateSettings
// unity command run_script --file AgentScripts/WL813tj_Apply.cs --entry WL813tj_Apply.Resave
// unity command run_script --file AgentScripts/WL813tj_Apply.cs --entry WL813tj_Apply.ApplySafeArea
// unity command run_script --file AgentScripts/WL813tj_Apply.cs --entry WL813tj_Apply.ApplyUi
// unity command run_script --file AgentScripts/WL813tj_Apply.cs --entry WL813tj_Apply.Revert
// 🔴 run_script 파일은 파일마다 독립 어셈블리 — 다른 AgentScript 상수 참조 불가(813c 교훈).
//
// ■ Safe Area 를 패널 **2개**로 나누는 이유 (실측 근거 · 완료보고 ⓒ)
// NewGameUI 캔버스 직속 12레이어에는 Canvas/sortingOrder 가 하나도 없다(실측: 5레이어 전부 컴포넌트 1~2개,
// Canvas 0개) → **그리는 순서 = 형제 순서**다. 대상 5레이어는 원본에서
// Common(4) · LobbyUIs(5) < IngameUIs(6) · FarmUIs(7) < MessageInfo(8) · Center(9) · PopupMgr(10) < TestMapUIMgr(11)
// 로 **IngameUIs/FarmUIs 를 사이에 끼고 앞뒤로 갈라져 있다**. 5개를 패널 하나에 몰면 앞뒤 관계가 뒤집힌다
// (로비 UI 가 인게임 UI 위로 올라오거나, 팝업이 인게임 UI 아래로 내려간다).
// 그래서 Back(Common·LobbyUIs) / Front(MessageInfo·Center·PopupMgr) 두 패널을 원래 자리에 끼워
// **평탄화하면 원본과 완전히 같은 순서**가 되게 했다.
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL813tj_Apply
{
public const string NewGameUIPath = "Assets/Res_Addr/MainUI/NewGameUI.prefab";
public const string SettingsDir = "Assets/WL/UI/Settings/Resources/WL";
public const string BossSettingsPath = SettingsDir + "/WLBossUiSettings.asset";
public const string SurvivalSettingsPath = SettingsDir + "/WLSurvivalUiSettings.asset";
public const string BackPanel = "WL_SafeArea_Back";
public const string FrontPanel = "WL_SafeArea_Front";
public static readonly string[] BackLayers = { "Common", "LobbyUIs" };
public static readonly string[] FrontLayers = { "MessageInfo", "Center", "PopupMgr" };
public const string HudPath = "IngameUIs/WL_HUD";
public const string PadPath = "IngameUIs/BattleUI";
public const string BannerName = "WL_BossBanner";
public const string HitName = "WL_HitVignette";
public const string ReviveName = "WL_ReviveDialog";
public const string PotionName = "WL_PotionButton";
const string TmpFontGuid = "a387e325271126742ad15569dba1ac57"; // 813c/813g 가 실측한 원본 TMP 폰트
const int UILayer = 5;
// ── 유틸 ─────────────────────────────────────────────────────────────────
static Transform FindByPath(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 Transform FindAnywhere(Transform root, string name)
{
for (int i = 0; i < root.childCount; i++)
{
var c = root.GetChild(i);
if (c.name == name) return c;
for (int j = 0; j < c.childCount; j++)
if (c.GetChild(j).name == name) return c.GetChild(j);
}
return null;
}
static void EnsureFolder(string dir)
{
var parts = dir.Split('/');
var cur = parts[0];
for (int i = 1; i < parts.Length; i++)
{
var next = cur + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]);
cur = next;
}
}
static RectTransform EnsureNode(Transform parent, string name, StringBuilder sb)
{
var t = parent.Find(name) as RectTransform;
if (t == null)
{
var go = new GameObject(name, typeof(RectTransform));
go.layer = UILayer;
t = (RectTransform)go.transform;
t.SetParent((RectTransform)parent, false);
sb.AppendLine(" " + parent.name + "/" + name + " : 노드 추가");
}
else sb.AppendLine(" " + parent.name + "/" + name + " : 이미 있음");
return t;
}
static TMPro.TMP_FontAsset LoadFont(StringBuilder sb)
{
var p = AssetDatabase.GUIDToAssetPath(TmpFontGuid);
var f = string.IsNullOrEmpty(p) ? null : AssetDatabase.LoadAssetAtPath<TMPro.TMP_FontAsset>(p);
sb.AppendLine("폰트: " + (f != null ? p : "「미확인」 — TMP 기본 폰트로 떨어진다"));
return f;
}
// ── ① 설정 에셋 ──────────────────────────────────────────────────────────
/// <summary>설정 에셋 2종 생성(이미 있으면 값 유지 · 덮어쓰지 않는다).</summary>
public static object CreateSettings()
{
var sb = new StringBuilder();
EnsureFolder(SettingsDir);
var boss = AssetDatabase.LoadAssetAtPath<WL.UI.WLBossUiSettings>(BossSettingsPath);
if (boss == null)
{
boss = ScriptableObject.CreateInstance<WL.UI.WLBossUiSettings>();
AssetDatabase.CreateAsset(boss, BossSettingsPath);
sb.AppendLine("생성: " + BossSettingsPath);
}
else sb.AppendLine("이미 있음(값 유지): " + BossSettingsPath);
var sur = AssetDatabase.LoadAssetAtPath<WL.UI.WLSurvivalUiSettings>(SurvivalSettingsPath);
if (sur == null)
{
sur = ScriptableObject.CreateInstance<WL.UI.WLSurvivalUiSettings>();
AssetDatabase.CreateAsset(sur, SurvivalSettingsPath);
sb.AppendLine("생성: " + SurvivalSettingsPath);
}
else sb.AppendLine("이미 있음(값 유지): " + SurvivalSettingsPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
WL.UI.WLBossUiSettings.ClearCache();
WL.UI.WLSurvivalUiSettings.ClearCache();
sb.AppendLine("── 813t 보스 배너(기준서 §D-1 813t)");
sb.AppendLine(" banner=" + boss.bannerSeconds + "s topPx=" + boss.bannerTopMarginPx + " sizePx=" + boss.bannerSizePx +
" title=\"" + boss.bannerTitleText + "\" punch=" + boss.bannerPunchScale);
sb.AppendLine(" warn=" + boss.warnVignetteSeconds + "s thickPx=" + boss.warnVignetteThicknessPx +
" pulses=" + boss.warnVignettePulses + " color=" + boss.warnVignetteColor);
sb.AppendLine("── 813j 생존 UI(기준서 §D-1 813j · §B 요소 8)");
sb.AppendLine(" 물약 slot=" + sur.potionReserveSlotIndex + " fallback(count=" + sur.potionFallbackCount +
" cd=" + sur.potionFallbackCooldownSeconds + "s) requireProvider=" + sur.potionRequireProvider);
sb.AppendLine(" 부활 delay=" + sur.reviveDelaySeconds + "s 공용팝업=" + sur.reviveUseCommonPopup +
" msg=\"" + sur.reviveMessage + "\" 비용=\"" + sur.reviveCostText + "\"");
sb.AppendLine(" 피격 border=" + sur.hitBorderSeconds + "s/" + sur.hitBorderThicknessPx + "px target=" + sur.hitBorderTargetPath +
" 무적무시=" + sur.ignoreInvincibleHits + " 스로틀=" + sur.hitMinIntervalSeconds + "s");
return sb.ToString();
}
/// <summary>무변경 재저장 — 프리팹 편집 전에 재직렬화 diff 가 생기는지 먼저 잰다(802b·813c 교훈).</summary>
public static object Resave()
{
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try { PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath); }
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.Refresh();
return NewGameUIPath + " : 무변경 재저장 (git diff 로 재직렬화 여부 확인)";
}
// ── ② Safe Area 실효화 ───────────────────────────────────────────────────
static RectTransform MakePanel(Transform root, string name, int siblingIndex, StringBuilder sb)
{
var t = root.Find(name) as RectTransform;
if (t == null)
{
var go = new GameObject(name, typeof(RectTransform));
go.layer = UILayer;
t = (RectTransform)go.transform;
t.SetParent((RectTransform)root, false);
sb.AppendLine(name + " : 패널 추가");
}
else sb.AppendLine(name + " : 이미 있음");
t.anchorMin = Vector2.zero; t.anchorMax = Vector2.one;
t.offsetMin = Vector2.zero; t.offsetMax = Vector2.zero;
t.pivot = new Vector2(0.5f, 0.5f);
t.localScale = Vector3.one;
t.localPosition = new Vector3(0f, 0f, 0f);
if (t.GetComponent<WL.UI.SafeAreaFitter>() == null)
{
t.gameObject.AddComponent<WL.UI.SafeAreaFitter>();
sb.AppendLine(" SafeAreaFitter 부착");
}
if (siblingIndex >= 0) t.SetSiblingIndex(Mathf.Min(siblingIndex, root.childCount - 1));
return t;
}
public static object ApplySafeArea()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
var rt = root.transform;
sb.AppendLine("── 적용 전 캔버스 루트 순서");
sb.AppendLine(" " + Order(rt));
// Back 패널 = 원래 Common 이 있던 자리
int backAt = IndexOf(rt, BackLayers[0]);
if (backAt < 0) return "🔴 " + BackLayers[0] + " 없음 — 중단";
var back = MakePanel(rt, BackPanel, backAt, sb);
foreach (var n in BackLayers) Reparent(rt, n, back, sb);
// Front 패널 = 원래 MessageInfo 가 있던 자리(Back 이동으로 인덱스가 당겨진 뒤 다시 잰다)
int frontAt = IndexOf(rt, FrontLayers[0]);
if (frontAt < 0) return "🔴 " + FrontLayers[0] + " 없음 — 중단";
var front = MakePanel(rt, FrontPanel, frontAt, sb);
foreach (var n in FrontLayers) Reparent(rt, n, front, sb);
sb.AppendLine("── 적용 후 캔버스 루트 순서(children=" + rt.childCount + ")");
sb.AppendLine(" " + Order(rt));
sb.AppendLine("── 평탄화(패널을 펼친 순서) = 원본과 같아야 한다");
sb.AppendLine(" " + Flatten(rt));
PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
static int IndexOf(Transform parent, string name)
{
for (int i = 0; i < parent.childCount; i++) if (parent.GetChild(i).name == name) return i;
return -1;
}
static void Reparent(Transform root, string name, RectTransform panel, StringBuilder sb)
{
var t = root.Find(name) as RectTransform;
if (t == null)
{
if (panel.Find(name) != null) { sb.AppendLine(" " + name + " : 이미 " + panel.name + " 아래"); return; }
sb.AppendLine(" 🔴 " + name + " 없음");
return;
}
var before = new { a0 = t.anchorMin, a1 = t.anchorMax, p = t.anchoredPosition, s = t.sizeDelta, sc = t.localScale, z = t.localPosition.z, pv = t.pivot };
t.SetParent(panel, false); // worldPositionStays=false → 로컬 값 그대로 보존
t.SetAsLastSibling();
bool same = before.a0 == t.anchorMin && before.a1 == t.anchorMax && before.p == t.anchoredPosition &&
before.s == t.sizeDelta && before.sc == t.localScale && Mathf.Approximately(before.z, t.localPosition.z) &&
before.pv == t.pivot;
sb.AppendLine(" " + name + " → " + panel.name + " (보존=" + same + " aMin" + t.anchorMin + " aMax" + t.anchorMax +
" off" + t.offsetMin + t.offsetMax + " scale" + t.localScale + " z=" + t.localPosition.z + ")");
}
static string Order(Transform t)
{
var sb = new StringBuilder();
for (int i = 0; i < t.childCount; i++) { if (i > 0) sb.Append(" · "); sb.Append(i).Append(':').Append(t.GetChild(i).name); }
return sb.ToString();
}
static string Flatten(Transform t)
{
var sb = new StringBuilder();
int k = 0;
for (int i = 0; i < t.childCount; i++)
{
var c = t.GetChild(i);
if (c.name.StartsWith("WL_SafeArea"))
{
for (int j = 0; j < c.childCount; j++) { if (k++ > 0) sb.Append(" · "); sb.Append(c.GetChild(j).name); }
}
else { if (k++ > 0) sb.Append(" · "); sb.Append(c.name); }
}
return sb.ToString();
}
// ── ③ UI 노드 ────────────────────────────────────────────────────────────
public static object ApplyUi()
{
var sb = new StringBuilder();
var boss = AssetDatabase.LoadAssetAtPath<WL.UI.WLBossUiSettings>(BossSettingsPath);
var sur = AssetDatabase.LoadAssetAtPath<WL.UI.WLSurvivalUiSettings>(SurvivalSettingsPath);
if (boss == null || sur == null) return "🔴 설정 에셋 없음 — CreateSettings 먼저";
var font = LoadFont(sb);
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
// ── 813t + 813j 화면 연출 = IngameUIs/WL_HUD (802b SafeAreaFitter 갈래 · 813c/813g 규칙)
var hud = FindByPath(root.transform, HudPath);
if (hud == null) sb.AppendLine("🔴 " + HudPath + " 없음");
else
{
var banner = EnsureNode(hud, BannerName, sb);
var bc = banner.GetComponent<WL.UI.BossBanner>();
if (bc == null) { bc = banner.gameObject.AddComponent<WL.UI.BossBanner>(); sb.AppendLine(" BossBanner 추가"); }
if (font != null) bc.SetFont(font);
sb.AppendLine(" build made=" + bc.BuildIfNeeded() + " · " + bc.ApplyLayout());
bc.HideNow();
var hit = EnsureNode(hud, HitName, sb);
var hc = hit.GetComponent<WL.UI.HitVignette>();
if (hc == null) { hc = hit.gameObject.AddComponent<WL.UI.HitVignette>(); sb.AppendLine(" HitVignette 추가"); }
sb.AppendLine(" build made=" + hc.BuildIfNeeded() + " · " + hc.ApplyLayout());
hc.HideNow(); // HP 바 테두리는 런타임(EnsureBorder)에서 만든다 = 프리팹 diff 0
var rev = EnsureNode(hud, ReviveName, sb);
var rc = rev.GetComponent<WL.UI.ReviveDialog>();
if (rc == null) { rc = rev.gameObject.AddComponent<WL.UI.ReviveDialog>(); sb.AppendLine(" ReviveDialog 추가"); }
if (font != null) rc.SetFont(font);
sb.AppendLine(" build made=" + rc.BuildIfNeeded() + " · " + rc.ApplyLayout());
rc.HideNow();
rev.SetAsLastSibling(); // 폴백 팝업은 HUD 최상단
}
// ── 813j 물약 버튼 = IngameUIs/BattleUI 의 813c 예약 슬롯 자리
var pad = FindByPath(root.transform, PadPath);
if (pad == null) sb.AppendLine("🔴 " + PadPath + " 없음");
else
{
var pot = EnsureNode(pad, PotionName, sb);
var pc = pot.GetComponent<WL.UI.PotionButton>();
if (pc == null) { pc = pot.gameObject.AddComponent<WL.UI.PotionButton>(); sb.AppendLine(" PotionButton 추가"); }
if (font != null) pc.SetFont(font);
sb.AppendLine(" build made=" + pc.BuildIfNeeded() + " · " + pc.ApplyLayout());
sb.AppendLine(" refresh " + pc.Refresh());
}
PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
// ── 롤백 ─────────────────────────────────────────────────────────────────
public static object Revert()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
// UI 노드
var hud = FindByPath(root.transform, HudPath);
if (hud != null)
foreach (var n in new[] { BannerName, HitName, ReviveName })
{
var t = hud.Find(n);
if (t != null) { Object.DestroyImmediate(t.gameObject); sb.AppendLine(HudPath + "/" + n + " : 제거"); }
}
var pad = FindByPath(root.transform, PadPath);
if (pad != null)
{
var t = pad.Find(PotionName);
if (t != null) { Object.DestroyImmediate(t.gameObject); sb.AppendLine(PadPath + "/" + PotionName + " : 제거"); }
}
// Safe Area 패널 — 자식을 원래 자리로 되돌리고 패널을 지운다
var rt = root.transform;
RestorePanel(rt, BackPanel, sb);
RestorePanel(rt, FrontPanel, sb);
sb.AppendLine("복원 후 순서: " + Order(rt));
PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
static void RestorePanel(Transform root, string panelName, StringBuilder sb)
{
var panel = root.Find(panelName) as RectTransform;
if (panel == null) { sb.AppendLine(panelName + " : 없음"); return; }
int at = panel.GetSiblingIndex();
while (panel.childCount > 0)
{
var c = panel.GetChild(0) as RectTransform;
c.SetParent(root as RectTransform, false);
c.SetSiblingIndex(at++);
sb.AppendLine(" " + c.name + " : 루트로 복원 @" + (at - 1));
}
Object.DestroyImmediate(panel.gameObject);
sb.AppendLine(panelName + " : 패널 제거");
}
}

View File

@ -0,0 +1,476 @@
// WL-813tj (#813) — 실측 프로브(Play 0 · 에디트 모드).
// unity command run_script --file AgentScripts/WL813tj_Probe.cs --entry WL813tj_Probe.DumpSafeArea
// unity command run_script --file AgentScripts/WL813tj_Probe.cs --entry WL813tj_Probe.DumpBoss
// unity command run_script --file AgentScripts/WL813tj_Probe.cs --entry WL813tj_Probe.DumpSurvival
// unity command run_script --file AgentScripts/WL813tj_Probe.cs --entry WL813tj_Probe.DumpPrefab
// 🔴 프리팹은 LoadPrefabContents 로 열고 **저장하지 않는다**(검증만). 구독은 전부 되돌린다.
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL813tj_Probe
{
public const string NewGameUIPath = "Assets/Res_Addr/MainUI/NewGameUI.prefab";
public const string HudPath = "IngameUIs/WL_HUD";
public const string PadPath = "IngameUIs/BattleUI";
static Transform FindByPath(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 void Save(string file, string body)
{
try { System.IO.File.WriteAllText(System.IO.Path.Combine("Logs", file), body); } catch { }
}
// ═══ ⓒ Safe Area ════════════════════════════════════════════════════════
struct Res { public string name; public int w, h; public Rect safe; public string src; }
static Res[] QaResolutions()
{
// QA 5종(802b·813c 와 같은 목록). safeArea 는 「추정」 — 실기 인셋은 빌드 전까지 확인 불가(C23).
return new[]
{
new Res { name="1080x1920", w=1080, h=1920, safe=new Rect(0, 0, 1080, 1920), src="노치 없음(전체)" },
new Res { name="720x1600", w= 720, h=1600, safe=new Rect(0, 0, 720, 1600), src="노치 없음(전체)" },
new Res { name="1440x3200", w=1440, h=3200, safe=new Rect(0, 0, 1440, 3200), src="노치 없음(전체)" },
new Res { name="1179x2556", w=1179, h=2556, safe=new Rect(0, 102, 1179, 2277), src="다이내믹 아일랜드 「추정」 top 177 / bottom 102 px" },
new Res { name="1080x2400", w=1080, h=2400, safe=new Rect(0, 0, 1080, 2310), src="펀치홀 「추정」 top 90 px" },
};
}
/// <summary>SafeAreaFitter.Apply 와 **같은 4줄**을 재현한다(Screen.safeArea 를 주입할 수 없어서).</summary>
static void FitterMath(Rect safe, int w, int h, bool applyH, bool applyV, out Vector2 min, out Vector2 max)
{
min = new Vector2(safe.xMin / w, safe.yMin / h);
max = new Vector2(safe.xMax / w, safe.yMax / h);
if (!applyH) { min.x = 0f; max.x = 1f; }
if (!applyV) { min.y = 0f; max.y = 1f; }
}
public static object DumpSafeArea()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
var rt = root.transform;
sb.AppendLine("[1] 캔버스 루트 순서 (children=" + rt.childCount + ")");
for (int i = 0; i < rt.childCount; i++)
{
var c = rt.GetChild(i);
var crt = c as RectTransform;
bool panel = c.name.StartsWith("WL_SafeArea");
sb.AppendLine(" [" + i + "] " + c.name + (panel ? " ← Safe Area 패널" : "") +
" active=" + c.gameObject.activeSelf +
(crt != null ? " aMin" + crt.anchorMin + " aMax" + crt.anchorMax +
" off" + crt.offsetMin + crt.offsetMax + " scale" + crt.localScale : ""));
if (!panel) continue;
var f = c.GetComponent<WL.UI.SafeAreaFitter>();
sb.AppendLine(" SafeAreaFitter=" + (f != null) + " Graphic=" + (c.GetComponent<UnityEngine.UI.Graphic>() != null) +
" comps=" + c.GetComponents<Component>().Length);
for (int j = 0; j < c.childCount; j++)
{
var g = c.GetChild(j) as RectTransform;
sb.AppendLine(" └ " + c.GetChild(j).name + " aMin" + g.anchorMin + " aMax" + g.anchorMax +
" off" + g.offsetMin + g.offsetMax + " scale" + g.localScale + " z=" + g.localPosition.z +
" active=" + g.gameObject.activeSelf);
}
}
sb.AppendLine();
sb.AppendLine("[2] 평탄화 순서(그리는 순서) = 원본과 같아야 회귀 0");
var flat = new StringBuilder();
for (int i = 0; i < rt.childCount; i++)
{
var c = rt.GetChild(i);
if (c.name.StartsWith("WL_SafeArea")) for (int j = 0; j < c.childCount; j++) flat.Append(c.GetChild(j).name).Append(" · ");
else flat.Append(c.name).Append(" · ");
}
sb.AppendLine(" 지금 : " + flat.ToString().TrimEnd(' ', '·'));
sb.AppendLine(" 원본 : New Image · Camera · Floating Joystick · CamRot · Common · LobbyUIs · IngameUIs · FarmUIs · MessageInfo · Center · PopupMgr · TestMapUIMgr");
sb.AppendLine();
sb.AppendLine("[3] SafeAreaFitter 산식 표 (QA 5해상도 · applyH=applyV=true)");
sb.AppendLine(" ※ Screen.safeArea 를 주입할 수 없어 SafeAreaFitter.Apply 의 4줄을 그대로 재현했다(802b 와 같은 방법).");
sb.AppendLine(" 해상도 | safeArea(x,y,w,h) | anchorMin | anchorMax | 화면 인셋(px) | 근거");
foreach (var r in QaResolutions())
{
Vector2 mn, mx;
FitterMath(r.safe, r.w, r.h, true, true, out mn, out mx);
float insetTop = r.h - r.safe.yMax, insetBottom = r.safe.yMin;
sb.AppendLine(" " + r.name.PadRight(12) + "| " +
("(" + r.safe.x + "," + r.safe.y + "," + r.safe.width + "," + r.safe.height + ")").PadRight(25) + "| " +
("(" + mn.x.ToString("F4") + ", " + mn.y.ToString("F4") + ")").PadRight(17) + "| " +
("(" + mx.x.ToString("F4") + ", " + mx.y.ToString("F4") + ")").PadRight(17) + "| " +
("top " + insetTop + " / bottom " + insetBottom).PadRight(22) + "| " + r.src);
}
sb.AppendLine(" offsetMin/offsetMax 는 언제나 (0,0) — 패널은 앵커만 움직인다(802b 와 동일).");
// 실측 1건: 에디터가 보고하는 실제 Screen 으로 진짜 컴포넌트를 돌려 재현식과 대조한다.
sb.AppendLine();
sb.AppendLine("[4] 실측 대조 1건 — 에디터 Screen(" + Screen.width + "x" + Screen.height + ") safeArea=" + Screen.safeArea);
foreach (var pn in new[] { "WL_SafeArea_Back", "WL_SafeArea_Front" })
{
var p = rt.Find(pn) as RectTransform;
if (p == null) { sb.AppendLine(" " + pn + " 없음"); continue; }
var f = p.GetComponent<WL.UI.SafeAreaFitter>();
if (f == null) { sb.AppendLine(" " + pn + " SafeAreaFitter 없음"); continue; }
f.Apply();
Vector2 mn, mx;
FitterMath(Screen.safeArea, Screen.width, Screen.height, true, true, out mn, out mx);
sb.AppendLine(" " + pn + " 실제 Apply → aMin" + p.anchorMin + " aMax" + p.anchorMax +
" off" + p.offsetMin + p.offsetMax + " | 재현식 aMin(" + mn.x.ToString("F4") + ", " + mn.y.ToString("F4") +
") aMax(" + mx.x.ToString("F4") + ", " + mx.y.ToString("F4") + ") | 일치=" +
(Vector2.Distance(p.anchorMin, mn) < 1e-5f && Vector2.Distance(p.anchorMax, mx) < 1e-5f));
// 검증만 — 프리팹은 저장하지 않는다
}
sb.AppendLine();
sb.AppendLine("[5] 회귀 — WLIngameUiOverride 경로(설정 에셋의 옛 문자열이 그대로 먹는가)");
var gs = WL.Settings.WLGameplaySettings.Instance;
if (gs == null) sb.AppendLine(" WLGameplaySettings 에셋 없음");
else
{
foreach (var p in gs.ingameHideCanvasGroupPaths ?? new string[0])
{
var t = WL.UI.WLVignetteUtil.FindUiPath(rt, p);
sb.AppendLine(" [canvasGroupHide] \"" + p + "\" → " + (t == null ? "🔴 없음" : "OK " + FullPath(rt, t)));
}
foreach (var p in gs.battlePadPaths ?? new string[0])
{
var t = WL.UI.WLVignetteUtil.FindUiPath(rt, p);
sb.AppendLine(" [battlePad] \"" + p + "\" → " + (t == null ? "🔴 없음" : "OK " + FullPath(rt, t) + " children=" + t.childCount));
}
var ui = root.GetComponent<NewGameUI>();
var common = (ui != null && ui.gos != null && ui.gos.Length > 0) ? ui.gos[0] : null;
sb.AppendLine(" [gos[0]] NewGameUI.gos[0]=" + (common == null ? "🔴 null" : common.name + " (참조 · 재부모화 무관) 부모=" +
(common.transform.parent != null ? common.transform.parent.name : "-")));
if (common != null)
foreach (var n in gs.ingameHideCommonObjectNames ?? new string[0])
sb.AppendLine(" [commonHide] \"" + n + "\" → " + (common.transform.Find(n) == null ? "🔴 없음" : "OK"));
sb.AppendLine(" hideBattlePad=" + gs.hideBattlePad + " (읽기만 · S 소유 · 값 전환은 Lead)");
}
// 813j 피격 테두리 대상도 같은 탐색기로 확인
var sur = WL.UI.WLSurvivalUiSettings.Instance;
if (sur != null)
{
var t = WL.UI.WLVignetteUtil.FindUiPath(rt, sur.hitBorderTargetPath);
sb.AppendLine(" [hitBorderTarget] \"" + sur.hitBorderTargetPath + "\" → " +
(t == null ? "🔴 없음" : "OK " + FullPath(rt, t) +
" size" + ((RectTransform)t).sizeDelta));
}
}
finally { PrefabUtility.UnloadPrefabContents(root); }
var body = sb.ToString();
Save("WL813tj_probe_safearea.txt", body);
return body;
}
static string FullPath(Transform root, Transform t)
{
var sb = new StringBuilder(t.name);
var cur = t.parent;
while (cur != null && cur != root) { sb.Insert(0, cur.name + "/"); cur = cur.parent; }
return sb.ToString();
}
// ═══ ⓑ 813t 보스 배너 · 경고 비네트 ═════════════════════════════════════
public static object DumpBoss()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
WL.UI.BossBanner bc = null;
try
{
var hud = FindByPath(root.transform, HudPath);
var node = hud != null ? hud.Find(WL813tj_ApplyNames.Banner) : null;
if (node == null) return "🔴 " + HudPath + "/" + WL813tj_ApplyNames.Banner + " 없음 — Apply 먼저";
bc = node.GetComponent<WL.UI.BossBanner>();
if (bc == null) return "🔴 BossBanner 컴포넌트 없음";
sb.AppendLine("[0] 초기화 — 런타임과 같은 경로(OnEnable→Initialize)");
sb.AppendLine(" 구독 전 Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count +
" BossPhase=" + WL.Combat.Boss.BossEvents.BossPhase.Count);
sb.AppendLine(" " + bc.Initialize());
sb.AppendLine(" 구독 후 Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count +
" BossPhase=" + WL.Combat.Boss.BossEvents.BossPhase.Count);
float t0 = Time.unscaledTime;
sb.AppendLine();
sb.AppendLine("[1] 비보스 스폰 → 반응 없음(미표시 규칙)");
sb.AppendLine(" " + WL.UI.BossBanner.RaiseFakeNonBossSpawned());
bc.Tick(t0);
sb.AppendLine(" visible=" + bc.Visible + " shows=" + bc.ShowCount + " (기대 False / 0)");
sb.AppendLine();
sb.AppendLine("[2] 보스 스폰(CombatEvents.Spawned isBoss=true) → 배너 + 비네트");
sb.AppendLine(" " + WL.UI.BossBanner.RaiseFakeBossSpawned());
var s = WL.UI.WLBossUiSettings.Instance;
float[] marks = { 0f, 0.05f, 0.15f, 0.20f, 0.40f, 0.79f, 0.81f, 1.0f, 1.19f, 1.21f };
sb.AppendLine(" t(s) | 배너α | 배너scale | 경고α | 상태");
foreach (var m in marks)
{
bc.Tick(t0 + m);
string state = (m < s.bannerSeconds ? "배너 " : "배너끝 ") + (m < s.warnVignetteSeconds ? "· 비네트" : "· 비네트끝");
sb.AppendLine(" " + m.ToString("F2").PadLeft(5) + " | " + bc.BannerAlpha.ToString("F3") +
" | " + bc.BannerScale.ToString("F3").PadLeft(8) +
" | " + bc.WarnAlpha.ToString("F3") + " | " + state);
}
sb.AppendLine(" shows=" + bc.ShowCount + " seen(Spawned/보스/Phase)=" + bc.SpawnedSeen + "/" + bc.BossSpawnSeen + "/" + bc.PhaseSeen);
sb.AppendLine();
sb.AppendLine("[3] 이름 승격 — BossEvents.BossPhase(isSpawn, monsterId) 가 MonsterList 이름을 준다");
sb.AppendLine(" 표 로드 여부: table_monsterlist.Ins=" + (table_monsterlist.Ins != null ? "있음" : "없음(에디트 모드)") +
" · table_localtext.Ins=" + (table_localtext.Ins != null ? "있음" : "없음"));
sb.AppendLine(" ResolveMonsterName(10006 Anubis)=" + (WL.UI.BossBanner.ResolveMonsterName(10006) ?? "(null · 표 미로드)"));
sb.AppendLine(" " + WL.UI.BossBanner.RaiseFakeBossPhaseSpawn(10006));
sb.AppendLine(" → " + bc.Dump().Split('\n')[0].Trim());
sb.AppendLine();
sb.AppendLine("[4] 직접 Trigger(이름 주입) — 표 없이도 이름 줄이 붙는지");
sb.AppendLine(" " + bc.ResetDiagnostics() + " (에디트 모드는 Time 이 멈춰 가드가 안 풀린다)");
sb.AppendLine(" " + bc.Trigger("아누비스", true));
bc.Tick(Time.unscaledTime + 0.2f);
sb.AppendLine();
sb.AppendLine("[5] 최종 덤프");
sb.Append(bc.Dump());
sb.AppendLine();
sb.AppendLine("[6] 813c 보스 HP 바와 같은 프레임 — 같은 Spawned Dispatch 를 함께 탄다");
var barNode = hud.Find("WL_BossHpBar");
var bar = barNode != null ? barNode.GetComponent<WL.UI.BossHpBar>() : null;
if (bar == null) sb.AppendLine(" 🔴 WL_BossHpBar 없음");
else
{
bar.Initialize();
bc.ResetDiagnostics();
sb.AppendLine(" 구독자 Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count + " (배너 + 보스바)");
int f0 = Time.frameCount;
sb.AppendLine(" " + WL.UI.BossHpBar.RaiseFakeBossSpawned(1000f));
bc.Tick(Time.unscaledTime + 0.2f); // 페이드인 0.15s 뒤(t=0 은 α 0 이 정상)
sb.AppendLine(" 같은 프레임(frame=" + f0 + "): 배너 shows=" + bc.ShowCount + " visible=" + bc.Visible +
" · 보스바 visible=" + bar.Visible + " ratio=" + bar.LastRatio.ToString("F3"));
bar.Subscribe(false);
}
}
finally
{
if (bc != null) bc.Subscribe(false);
PrefabUtility.UnloadPrefabContents(root);
}
sb.AppendLine();
sb.AppendLine("해제 후 구독자 Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count +
" BossPhase=" + WL.Combat.Boss.BossEvents.BossPhase.Count + " (0 이어야 한다)");
var body = sb.ToString();
Save("WL813tj_probe_boss.txt", body);
return body;
}
// ═══ ⓑ 813j 물약 · 부활 · 피격 ══════════════════════════════════════════
public static object DumpSurvival()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
WL.UI.HitVignette hv = null;
try
{
var hud = FindByPath(root.transform, HudPath);
var pad = FindByPath(root.transform, PadPath);
var s = WL.UI.WLSurvivalUiSettings.Instance;
if (s == null) return "🔴 WLSurvivalUiSettings 에셋 없음";
// ── A. 피격 테두리 + 가장자리 비네트
var hvNode = hud != null ? hud.Find(WL813tj_ApplyNames.Hit) : null;
hv = hvNode != null ? hvNode.GetComponent<WL.UI.HitVignette>() : null;
sb.AppendLine("[A] 피격 연출 (CombatEvents.Damaged · 실측 발생지 MyActor.cs:309 = PC 전용)");
if (hv == null) sb.AppendLine(" 🔴 WL_HitVignette 없음");
else
{
sb.AppendLine(" 구독 전 Damaged=" + WL.Combat.Core.CombatEvents.Damaged.Count);
sb.AppendLine(" " + hv.Initialize());
sb.AppendLine(" 구독 후 Damaged=" + WL.Combat.Core.CombatEvents.Damaged.Count);
float t0 = Time.unscaledTime;
sb.AppendLine(" ① 무적 피격(invincible=true · dmg 0) ×3 → 무시되어야 한다");
for (int i = 0; i < 3; i++) WL.UI.HitVignette.RaiseFakeDamaged(0, true);
sb.AppendLine(" seen=" + hv.DamagedSeen + " 무적스킵=" + hv.InvincibleSkipped + " flashes=" + hv.FlashCount +
" borderα=" + hv.BorderAlpha.ToString("F3") + " (기대 flashes=0)");
sb.AppendLine(" ② 실피격(invincible=false · dmg 120) → 번쩍");
sb.AppendLine(" " + WL.UI.HitVignette.RaiseFakeDamaged(120, false));
sb.AppendLine(" t(s) | 테두리α | 가장자리α");
foreach (var m in new[] { 0f, 0.10f, 0.20f, 0.29f, 0.31f })
{
hv.Tick(t0 + m);
sb.AppendLine(" " + m.ToString("F2").PadLeft(5) + " | " + hv.BorderAlpha.ToString("F3").PadLeft(7) +
" | " + hv.EdgeAlpha.ToString("F3"));
}
sb.AppendLine(" ③ 같은 프레임 연타 → 스로틀(" + s.hitMinIntervalSeconds + "s)");
int before = hv.FlashCount;
for (int i = 0; i < 4; i++) WL.UI.HitVignette.RaiseFakeDamaged(50, false);
sb.AppendLine(" 추가 요청 4 → flashes " + before + "→" + hv.FlashCount + " 스로틀스킵=" + hv.ThrottleSkipped);
sb.AppendLine();
sb.Append(hv.Dump());
hv.RemoveBorder(); // 프리팹에 굽지 않는다 — 검증 후 즉시 제거
}
// ── B. 부활 팝업
sb.AppendLine();
sb.AppendLine("[B] 부활 팝업 (공용 Popup/SortOrder_5 재사용 · 813i 미연결)");
var rvNode = hud != null ? hud.Find(WL813tj_ApplyNames.Revive) : null;
var rv = rvNode != null ? rvNode.GetComponent<WL.UI.ReviveDialog>() : null;
if (rv == null) sb.AppendLine(" 🔴 WL_ReviveDialog 없음");
else
{
sb.AppendLine(" " + rv.Initialize());
float t0 = Time.unscaledTime;
sb.AppendLine(" 813i 진입점 NotifyDeath() → " + WL.UI.ReviveDialog.NotifyDeath());
rv.Tick(t0 + s.reviveDelaySeconds - 0.01f);
sb.AppendLine(" t=" + (s.reviveDelaySeconds - 0.01f).ToString("F2") + "s : 표시=" + rv.FallbackVisible + " 대기중=" + rv.Pending + " (기대 False/True)");
rv.Tick(t0 + s.reviveDelaySeconds);
sb.AppendLine(" t=" + s.reviveDelaySeconds.ToString("F2") + "s : 표시=" + rv.FallbackVisible +
" 공용팝업사용=" + rv.UsedCommonPopup + " shows=" + rv.ShownCount);
sb.AppendLine(" 본문=\"" + rv.LastMessage.Replace("\n", " / ") + "\"");
bool got = false;
WL.UI.ReviveDialog.ReviveRequested = () => got = true;
sb.AppendLine(" 확인 → " + rv.OnConfirm() + " · 813i 콜백 수신=" + got + " · 표시=" + rv.FallbackVisible);
WL.UI.ReviveDialog.ReviveRequested = null;
sb.AppendLine();
sb.Append(rv.Dump());
}
// ── C. 물약 버튼
sb.AppendLine();
sb.AppendLine("[C] 물약 버튼 (813c 예약 슬롯 · 813i Provider 미연결 → 대역으로 왕복 검증)");
var ptNode = pad != null ? pad.Find(WL813tj_ApplyNames.Potion) : null;
var pt = ptNode != null ? ptNode.GetComponent<WL.UI.PotionButton>() : null;
if (pt == null) sb.AppendLine(" 🔴 WL_PotionButton 없음");
else
{
WL.UI.PotionButton.ClearProvider();
sb.AppendLine(" " + pt.Initialize());
sb.AppendLine(" ① 813i 미연결: HasProvider=" + WL.UI.PotionButton.HasProvider + " · " + pt.Refresh());
sb.AppendLine(" 클릭 → " + pt.OnClickPotion() + " (used=" + pt.UsedCount + " blocked=" + pt.BlockedCount + ")");
sb.AppendLine(" ② 813i 대역 설치: " + WL.UI.PotionButton.InstallFakeProvider(3, 5f));
sb.AppendLine(" " + pt.Refresh());
sb.AppendLine(" 클릭1 → " + pt.OnClickPotion() + " · " + pt.Refresh());
sb.AppendLine(" 쿨 중 클릭2 → " + pt.OnClickPotion() + " · " + pt.Refresh());
if (WL.UI.PotionButton.FakeCooldownSetter != null) WL.UI.PotionButton.FakeCooldownSetter(2.5f);
sb.AppendLine(" 쿨 절반(2.5/5) → " + pt.Refresh());
if (WL.UI.PotionButton.FakeCooldownSetter != null) WL.UI.PotionButton.FakeCooldownSetter(0f);
sb.AppendLine(" 쿨 종료 → " + pt.Refresh());
sb.AppendLine(" 클릭3 → " + pt.OnClickPotion());
if (WL.UI.PotionButton.FakeCooldownSetter != null) WL.UI.PotionButton.FakeCooldownSetter(0f);
sb.AppendLine(" 클릭4 → " + pt.OnClickPotion() + " · " + pt.Refresh());
if (WL.UI.PotionButton.FakeCooldownSetter != null) WL.UI.PotionButton.FakeCooldownSetter(0f);
sb.AppendLine(" 잔량 0 클릭5 → " + pt.OnClickPotion() + " · " + pt.Refresh());
sb.AppendLine();
sb.Append(pt.Dump());
sb.AppendLine(" " + WL.UI.PotionButton.ClearProvider());
}
}
finally
{
if (hv != null) hv.Subscribe(false);
PrefabUtility.UnloadPrefabContents(root);
}
sb.AppendLine();
sb.AppendLine("해제 후 구독자 Damaged=" + WL.Combat.Core.CombatEvents.Damaged.Count + " (0 이어야 한다)");
var body = sb.ToString();
Save("WL813tj_probe_survival.txt", body);
return body;
}
// ═══ 노드 배치 · 겹침 ═══════════════════════════════════════════════════
public static object DumpPrefab()
{
var sb = new StringBuilder();
var root = PrefabUtility.LoadPrefabContents(NewGameUIPath);
try
{
var hud = FindByPath(root.transform, HudPath);
var pad = FindByPath(root.transform, PadPath);
sb.AppendLine("[WL_HUD] children=" + (hud != null ? hud.childCount : -1) +
" SafeAreaFitter=" + (hud != null && hud.GetComponent<WL.UI.SafeAreaFitter>() != null));
if (hud != null)
for (int i = 0; i < hud.childCount; i++)
{
var c = hud.GetChild(i) as RectTransform;
sb.AppendLine(" [" + i + "] " + c.name + " pos" + c.anchoredPosition + " size" + c.sizeDelta +
" aMin" + c.anchorMin + " children=" + c.childCount + " comps=" + c.GetComponents<Component>().Length);
}
sb.AppendLine("[BattleUI] children=" + (pad != null ? pad.childCount : -1));
if (pad != null)
for (int i = 0; i < pad.childCount; i++)
{
var c = pad.GetChild(i) as RectTransform;
sb.AppendLine(" [" + i + "] " + c.name + " pos" + c.anchoredPosition + " size" + c.sizeDelta +
" aMin" + c.anchorMin + " active=" + c.gameObject.activeSelf);
}
// 배너 · 보스바 세로 겹침 검사(px)
var hudS = WL.UI.WLHudLayoutSettings.Instance;
var bs = WL.UI.WLBossUiSettings.Instance;
if (hudS != null && bs != null)
{
float barTop = hudS.bossBarTopMarginPx;
float barBottom = barTop + hudS.bossBarSizePx.y + hudS.bossBarNameFontPx + hudS.bossBarNameGapPx;
float banTop = bs.bannerTopMarginPx, banBottom = banTop + bs.bannerSizePx.y;
sb.AppendLine();
sb.AppendLine("[겹침 · 상단에서 px] 보스바 " + barTop.ToString("F0") + "~" + barBottom.ToString("F0") +
" · 배너 " + banTop.ToString("F0") + "~" + banBottom.ToString("F0") +
" → 겹침=" + (banTop < barBottom));
sb.AppendLine(" 813g 연쇄문구 = 화면 중앙 위 " + (WL.UI.WLCombatTextSettings.Instance != null ?
WL.UI.WLCombatTextSettings.Instance.killChainTopOffsetPx.ToString("F0") : "?") + " px (중앙 기준 · 상단 밴드와 별개)");
}
// 물약 버튼과 813c 슬롯 간 최소 여백
if (hudS != null)
{
var sur = WL.UI.WLSurvivalUiSettings.Instance;
int idx = sur != null ? sur.potionReserveSlotIndex : 0;
Vector2 pp = hudS.ReserveSlotPx(idx);
float pr = hudS.reserveDiameterPx * 0.5f;
float min = float.MaxValue; string who = "";
for (int i = 0; i < hudS.activeSkillSlots; i++)
{
var q = hudS.FanSlotPx(i);
float g = Vector2.Distance(pp, q) - pr - hudS.slotDiameterPx * 0.5f;
if (g < min) { min = g; who = "slot" + i; }
}
float ga = Vector2.Distance(pp, hudS.attackCenterPx) - pr - hudS.attackDiameterPx * 0.5f;
if (ga < min) { min = ga; who = "Attack"; }
sb.AppendLine();
sb.AppendLine("[물약 버튼] 예약 슬롯" + idx + " dx/dy px=" + pp + " 지름=" + hudS.reserveDiameterPx +
"px · 최소 여백=" + min.ToString("F1") + "px (" + who + ") · 기준 ≥16 → " + (min >= 16f ? "OK" : "🔴"));
sb.AppendLine(" 모서리 도달 반경 = " + (pp.magnitude + pr).ToString("F1") + "px (기준서 목표 " + hudS.thumbReachRadiusPx + "px)");
}
}
finally { PrefabUtility.UnloadPrefabContents(root); }
var body = sb.ToString();
Save("WL813tj_probe_prefab.txt", body);
return body;
}
}
/// <summary>Apply 스크립트와 이름을 맞추기 위한 상수(run_script 는 파일마다 독립 어셈블리라 참조가 안 된다 · 813c 교훈).</summary>
public static class WL813tj_ApplyNames
{
public const string Banner = "WL_BossBanner";
public const string Hit = "WL_HitVignette";
public const string Revive = "WL_ReviveDialog";
public const string Potion = "WL_PotionButton";
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,447 @@
// ─────────────────────────────────────────────────────────────────────────────
// BossBanner.cs — 보스 등장 배너 + 경고 비네트 (WL-813tj · 813t · #813)
//
// 기준서 v1 §D-1 813t 행 / §B 요소 6 "등장 · 처치 연출":
// Spawned(isBoss) → 상단 "BOSS" 배너 1.2 s + 화면 가장자리 붉은 UI 비네트 0.8 s
// (포스트 프로세싱 금지 → UI 이미지) · 보스 이름 = MonsterList.n_MonsterName · 811d 오빗과 동시.
//
// ■ 이벤트 (구독만 · 발행 0)
// ① WL.Combat.Core.CombatEvents.Spawned(isBoss) — 813c 보스 HP 바와 **같은 디스패치**를 탄다
// → 같은 프레임에 배너와 바가 함께 뜬다(구독 순서와 무관 · 같은 Dispatch 안).
// ② WL.Combat.Boss.BossEvents.BossPhase(isSpawn) — 813h 가 내는 페이즈 1 진입.
// 이쪽만 **monsterId** 를 준다 → 이름은 여기서 나온다. 먼저 온 쪽이 배너를 열고,
// 뒤늦게 이름이 오면 표시 중인 배너의 이름 줄만 갱신한다(중복 표시 없음).
// 이벤트가 없으면 아무 것도 뜨지 않는다 — 폴링 폴백을 두지 않는다(813c 규칙).
//
// ■ 값 = WLBossUiSettings.asset (C45) · px→유닛 = 813c WLHudLayoutSettings.UnitsPerPx 하나만.
// ■ 숨김 = CanvasGroup alpha (GameObject 를 끄면 OnEnable 이 안 돌아 구독이 끊긴다 · 813c 규칙)
// ■ 시간 = Time.unscaledTime (811b 히트스톱·킬캠 슬로모 중에도 배너 길이가 흔들리지 않게)
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Boss;
using WL.Combat.Core;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class BossBanner : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform bannerBox;
[SerializeField] private Image bannerBg;
[SerializeField] private TextMeshProUGUI titleLabel;
[SerializeField] private TextMeshProUGUI nameLabel;
[SerializeField] private RectTransform warnRoot;
[Header("폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset font;
private RectTransform _rt;
private Canvas _canvas;
private WLEdgeVignette _warn;
private bool _subscribed;
private float _bannerStart = -1f; // 배너 시작 unscaledTime (-1 = 꺼짐)
private float _warnStart = -1f;
private float _lastTrigger = -999f;
private string _shownName = "";
private bool _nameKnown;
// ── 진단(프로브가 읽는다) ────────────────────────────────────────────
public int SpawnedSeen { get; private set; }
public int BossSpawnSeen { get; private set; }
public int PhaseSeen { get; private set; }
public int ShowCount { get; private set; }
public string LastName { get { return _shownName; } }
public bool Visible { get { return group != null && group.alpha > 0.001f; } }
public float BannerAlpha { get { return group != null ? group.alpha : 0f; } }
public float WarnAlpha { get { return _warn != null ? _warn.CurrentAlpha : 0f; } }
public float BannerScale { get { return bannerBox != null ? bannerBox.localScale.x : 0f; } }
private void Awake() { _rt = GetComponent<RectTransform>(); }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// <summary>OnEnable 본체 — 에디트 모드 검증이 런타임과 같은 경로를 타도록 분리했다(813c 교훈).</summary>
public string Initialize()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
HideNow();
var s = WLBossUiSettings.Instance;
Subscribe(s == null || s.bannerEnabled);
return "initialized subscribed=" + _subscribed + " · " + layout;
}
public void Subscribe(bool on)
{
if (on == _subscribed) return;
_subscribed = on;
if (on)
{
CombatEvents.Spawned.Add(OnSpawned);
BossEvents.BossPhase.Add(OnBossPhase);
}
else
{
CombatEvents.Spawned.Remove(OnSpawned);
BossEvents.BossPhase.Remove(OnBossPhase);
}
}
// ── 구독 ──────────────────────────────────────────────────────────────
private void OnSpawned(in SpawnedEvent e)
{
SpawnedSeen++;
if (!e.isBoss) return;
BossSpawnSeen++;
string n = e.actor != null ? e.actor.name : null;
Trigger(n, false);
}
private void OnBossPhase(in BossPhaseEvent e)
{
PhaseSeen++;
if (!e.isSpawn) return;
string n = ResolveMonsterName(e.monsterId);
Trigger(n, !string.IsNullOrEmpty(n));
}
/// <summary>MonsterList.n_MonsterName → localtext. 표가 없으면(에디트 모드) null.</summary>
public static string ResolveMonsterName(int monsterId)
{
if (monsterId <= 0) return null;
try
{
if (table_monsterlist.Ins == null) return null;
var d = table_monsterlist.Ins.Get_Data_orNull(monsterId);
if (d == null) return null;
if (table_localtext.Ins == null) return null;
return d.Get_Name();
}
catch { return null; } // 표 미로드 상태에서 연출이 전투를 끊지 않게
}
/// <summary>
/// 배너를 연다. 이미 표시 중이면 이름만 승격한다(같은 스폰에서 Spawned·BossPhase 가 둘 다 오는 경우).
/// </summary>
public string Trigger(string bossName, bool nameIsAuthoritative)
{
var s = WLBossUiSettings.Instance;
if (s == null) return "WLBossUiSettings 에셋 없음 — 표시 안 함";
if (!s.bannerEnabled) return "bannerEnabled=false — 표시 안 함";
float now = Time.unscaledTime;
bool showing = _bannerStart >= 0f && now - _bannerStart < s.bannerSeconds;
if (showing || now - _lastTrigger < s.retriggerGuardSeconds)
{
// 중복 트리거 — 더 정확한 이름이 왔을 때만 갱신한다.
if (nameIsAuthoritative && !string.IsNullOrEmpty(bossName)) SetName(bossName, true, s);
return "재트리거 억제(guard " + s.retriggerGuardSeconds.ToString("F1") + "s) name=" + _shownName;
}
_lastTrigger = now;
_bannerStart = now;
_warnStart = s.warnVignetteEnabled ? now : -1f;
ShowCount++;
SetName(bossName, nameIsAuthoritative, s);
ApplyLayout();
Tick(now);
if (s.verboseLog) Debug.Log("[BossBanner] show name=" + _shownName + " banner=" + s.bannerSeconds + "s warn=" + s.warnVignetteSeconds + "s");
return "shown name=" + (string.IsNullOrEmpty(_shownName) ? "(없음)" : _shownName) +
" banner=" + s.bannerSeconds.ToString("F2") + "s warn=" + (_warnStart >= 0f ? s.warnVignetteSeconds.ToString("F2") + "s" : "off");
}
private void SetName(string bossName, bool authoritative, WLBossUiSettings s)
{
if (authoritative || !_nameKnown)
{
_shownName = bossName ?? "";
_nameKnown = authoritative;
}
if (titleLabel != null) titleLabel.text = s.bannerTitleText;
if (nameLabel != null)
{
nameLabel.text = _shownName;
bool show = !(s.hideNameWhenUnknown && string.IsNullOrEmpty(_shownName));
if (nameLabel.gameObject.activeSelf != show) nameLabel.gameObject.SetActive(show);
}
}
private void Update() { Tick(Time.unscaledTime); }
/// <summary>배너·비네트 진행. 에디트 모드 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
public void Tick(float now)
{
var s = WLBossUiSettings.Instance;
if (s == null) return;
// ── 배너
if (_bannerStart >= 0f)
{
float t = now - _bannerStart;
if (t >= s.bannerSeconds) { _bannerStart = -1f; SetBannerAlpha(0f); }
else
{
float a = 1f;
if (s.bannerFadeInSeconds > 0f && t < s.bannerFadeInSeconds) a = t / s.bannerFadeInSeconds;
float outStart = s.bannerSeconds - s.bannerFadeOutSeconds;
if (s.bannerFadeOutSeconds > 0f && t > outStart) a = Mathf.Min(a, (s.bannerSeconds - t) / s.bannerFadeOutSeconds);
SetBannerAlpha(Mathf.Clamp01(a));
// 등장 펀치 → 원래 크기로 수렴(813g 텍스트 연출과 같은 계열)
if (bannerBox != null)
{
float k = (s.bannerPunchSeconds > 0f) ? Mathf.Clamp01(t / s.bannerPunchSeconds) : 1f;
float sc = Mathf.Lerp(s.bannerPunchScale, 1f, k);
bannerBox.localScale = new Vector3(sc, sc, 1f);
}
}
}
// ── 경고 비네트
if (_warnStart >= 0f && _warn != null && _warn.IsBuilt)
{
float t = now - _warnStart;
if (t >= s.warnVignetteSeconds) { _warnStart = -1f; _warn.SetAlpha(s.warnVignetteColor, 0f); }
else
{
_warn.SetAlpha(s.warnVignetteColor, PulseCurve(t, s.warnVignetteSeconds, s.warnVignetteFadeInSeconds, s.warnVignettePulses));
}
}
}
/// <summary>0→1→0 맥동. pulses 회 반복하고, 첫 상승만 fadeIn 시간을 따른다.</summary>
public static float PulseCurve(float t, float total, float fadeIn, int pulses)
{
if (total <= 0f) return 0f;
int n = Mathf.Max(1, pulses);
float seg = total / n;
float local = t - Mathf.Floor(t / seg) * seg;
float up = (t < seg && fadeIn > 0f) ? Mathf.Min(fadeIn, seg * 0.5f) : seg * 0.5f;
if (local < up) return Mathf.Clamp01(local / up);
float down = seg - up;
return down <= 0f ? 0f : Mathf.Clamp01(1f - (local - up) / down);
}
private void SetBannerAlpha(float a)
{
if (group == null) return;
group.alpha = a;
group.blocksRaycasts = false;
group.interactable = false;
}
/// <summary>프로브 전용 — 카운터와 재트리거 가드를 지운다(에디트 모드는 시간이 흐르지 않는다).</summary>
public string ResetDiagnostics()
{
SpawnedSeen = BossSpawnSeen = PhaseSeen = ShowCount = 0;
_lastTrigger = -999f; _shownName = ""; _nameKnown = false;
HideNow();
return "진단 초기화(가드 해제)";
}
public void HideNow()
{
_bannerStart = -1f; _warnStart = -1f;
SetBannerAlpha(0f);
var s = WLBossUiSettings.Instance;
if (_warn != null && _warn.IsBuilt) _warn.SetAlpha(s != null ? s.warnVignetteColor : Color.red, 0f);
if (bannerBox != null) bannerBox.localScale = Vector3.one;
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
/// <summary>없는 자식만 만든다(에디터 오소링과 런타임이 같은 코드를 쓴다).</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
bool made = false;
if (group == null) { group = GetComponent<CanvasGroup>(); if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; } }
if (bannerBox == null) { bannerBox = WLVignetteUtil.NewChild(_rt, "Banner"); made = true; }
if (bannerBg == null)
{
var bg = WLVignetteUtil.NewChild(bannerBox, "Bg");
WLVignetteUtil.Stretch(bg);
bannerBg = bg.GetComponent<Image>();
if (bannerBg == null) bannerBg = bg.gameObject.AddComponent<Image>();
bannerBg.raycastTarget = false;
made = true;
}
if (titleLabel == null)
{
var t = WLVignetteUtil.NewChild(bannerBox, "Title");
titleLabel = t.GetComponent<TextMeshProUGUI>();
if (titleLabel == null) titleLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
titleLabel.raycastTarget = false;
titleLabel.alignment = TextAlignmentOptions.Center;
if (font != null) titleLabel.font = font;
made = true;
}
if (nameLabel == null)
{
var t = WLVignetteUtil.NewChild(bannerBox, "Name");
nameLabel = t.GetComponent<TextMeshProUGUI>();
if (nameLabel == null) nameLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
nameLabel.raycastTarget = false;
nameLabel.alignment = TextAlignmentOptions.Center;
if (font != null) nameLabel.font = font;
made = true;
}
_warn = WLVignetteUtil.Build(_rt, "Warn");
if (warnRoot == null) { warnRoot = _warn.root; made = true; }
return made;
}
/// <summary>설정 값(px)을 캔버스 유닛으로 환산해 배너·비네트를 맞춘다.</summary>
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLBossUiSettings.Instance;
if (s == null) return "WLBossUiSettings 에셋 없음 — 배치 건너뜀";
if (bannerBox == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = UnitsPerPx();
// 루트 = Safe Area 전체(부모 = SafeAreaFitter 가 붙은 WL_HUD) → 비네트가 안전 영역 가장자리를 탄다
WLVignetteUtil.Stretch(_rt);
_rt.localScale = Vector3.one;
// 배너 상자 = 상단 가운데
bannerBox.anchorMin = new Vector2(0.5f, 1f);
bannerBox.anchorMax = new Vector2(0.5f, 1f);
bannerBox.pivot = new Vector2(0.5f, 1f);
bannerBox.anchoredPosition = new Vector2(0f, -s.bannerTopMarginPx * u);
bannerBox.sizeDelta = new Vector2(s.bannerSizePx.x * u, s.bannerSizePx.y * u);
if (bannerBg != null)
{
bannerBg.color = s.bannerBgColor;
bannerBg.enabled = s.bannerBgColor.a > 0.001f;
}
float titleH = s.bannerTitleFontPx * u;
float nameH = s.bannerNameFontPx * u;
if (titleLabel != null)
{
var t = titleLabel.rectTransform;
t.anchorMin = new Vector2(0f, 1f); t.anchorMax = new Vector2(1f, 1f); t.pivot = new Vector2(0.5f, 1f);
t.offsetMin = new Vector2(0f, 0f); t.offsetMax = new Vector2(0f, 0f);
t.anchoredPosition = Vector2.zero;
t.sizeDelta = new Vector2(0f, titleH);
titleLabel.fontSize = titleH;
titleLabel.color = s.bannerTitleColor;
titleLabel.text = s.bannerTitleText;
if (font != null && titleLabel.font != font) titleLabel.font = font;
}
if (nameLabel != null)
{
var t = nameLabel.rectTransform;
t.anchorMin = new Vector2(0f, 1f); t.anchorMax = new Vector2(1f, 1f); t.pivot = new Vector2(0.5f, 1f);
t.offsetMin = new Vector2(0f, 0f); t.offsetMax = new Vector2(0f, 0f);
t.anchoredPosition = new Vector2(0f, -titleH);
t.sizeDelta = new Vector2(0f, nameH);
nameLabel.fontSize = nameH;
nameLabel.color = s.bannerNameColor;
if (font != null && nameLabel.font != font) nameLabel.font = font;
}
if (_warn == null || !_warn.IsBuilt) _warn = WLVignetteUtil.Build(_rt, "Warn");
WLVignetteUtil.ApplyLayout(_warn, s.warnVignetteThicknessPx, u, s.warnVignetteColor);
if (warnRoot == null) warnRoot = _warn.root;
if (warnRoot != null) warnRoot.SetAsFirstSibling(); // 비네트는 배너 뒤에
return "layout banner pos" + bannerBox.anchoredPosition + " size" + bannerBox.sizeDelta +
" unitsPerPx=" + u.ToString("F4") + " warnThickness=" + (s.warnVignetteThicknessPx * u).ToString("F1");
}
public float UnitsPerPx()
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
return WLVignetteUtil.UnitsPerPx(_canvas);
}
/// <summary>에디터가 폰트를 주입한다(런타임 로드 없음).</summary>
public void SetFont(TMP_FontAsset f)
{
font = f;
if (f == null) return;
if (titleLabel != null) titleLabel.font = f;
if (nameLabel != null) nameLabel.font = f;
}
// ── 검증 전용 (발주서 ⓑ) ─────────────────────────────────────────────
/// <summary>가짜 보스 스폰을 **실제 CombatEvents 경로로** 발행한다(Play 0 검증용).</summary>
public static string RaiseFakeBossSpawned()
{
var e = new SpawnedEvent { actor = null, isBoss = true, isElite = false, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Spawned.Dispatch(in e);
return "dispatched Spawned(isBoss=true) subscribers=" + CombatEvents.Spawned.Count;
}
public static string RaiseFakeNonBossSpawned()
{
var e = new SpawnedEvent { actor = null, isBoss = false, isElite = false, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Spawned.Dispatch(in e);
return "dispatched Spawned(isBoss=false) subscribers=" + CombatEvents.Spawned.Count;
}
/// <summary>가짜 보스 페이즈 1 진입(813h 경로) — 이름 승격 검증용.</summary>
public static string RaiseFakeBossPhaseSpawn(int monsterId)
{
var e = new BossPhaseEvent
{
boss = null, monsterId = monsterId, prevPhase = 0, phase = 1, phaseCount = 3,
hpPercent = 1f, threshold = 1f, isSpawn = true, time = Time.unscaledTime, frame = Time.frameCount
};
BossEvents.BossPhase.Dispatch(in e);
return "dispatched BossPhase(isSpawn=true id=" + monsterId + ") subscribers=" + BossEvents.BossPhase.Count;
}
public string Dump()
{
var sb = new StringBuilder();
var s = WLBossUiSettings.Instance;
sb.AppendLine("[BossBanner] subscribed=" + _subscribed + " visible=" + Visible +
" bannerAlpha=" + BannerAlpha.ToString("F3") + " warnAlpha=" + WarnAlpha.ToString("F3") +
" scale=" + BannerScale.ToString("F3") +
" name=\"" + _shownName + "\"(known=" + _nameKnown + ")" +
" seen(Spawned/Boss/Phase)=" + SpawnedSeen + "/" + BossSpawnSeen + "/" + PhaseSeen +
" shows=" + ShowCount);
sb.AppendLine(" 구독자 Spawned=" + CombatEvents.Spawned.Count + " BossPhase=" + BossEvents.BossPhase.Count +
" CombatEvents.Enabled=" + CombatEvents.Enabled + " BossEvents.Enabled=" + BossEvents.Enabled);
if (bannerBox != null)
sb.AppendLine(" banner pos" + bannerBox.anchoredPosition + " size" + bannerBox.sizeDelta +
" aMin" + bannerBox.anchorMin + " title=\"" + (titleLabel != null ? titleLabel.text : "-") +
"\" fs=" + (titleLabel != null ? titleLabel.fontSize.ToString("F1") : "-") +
" nameActive=" + (nameLabel != null && nameLabel.gameObject.activeSelf));
if (_warn != null && _warn.IsBuilt)
for (int i = 0; i < _warn.strips.Length; i++)
{
var img = _warn.strips[i];
if (img == null) continue;
var r = img.rectTransform;
sb.AppendLine(" warn[" + img.name + "] size" + r.sizeDelta + " aMin" + r.anchorMin + " aMax" + r.anchorMax +
" scale" + r.localScale + " enabled=" + img.enabled + " a=" + img.color.a.ToString("F3") +
" sprite=" + (img.sprite != null ? img.sprite.name : "(없음)") + " raycast=" + img.raycastTarget);
}
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"banner=" + s.bannerSeconds + "s warn=" + s.warnVignetteSeconds + "s topPx=" + s.bannerTopMarginPx +
" sizePx=" + s.bannerSizePx + " thickPx=" + s.warnVignetteThicknessPx + " pulses=" + s.warnVignettePulses));
return sb.ToString();
}
}
}

View File

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

View File

@ -0,0 +1,287 @@
// ─────────────────────────────────────────────────────────────────────────────
// HitVignette.cs — 피격 연출: HP 바 붉은 테두리 0.3 s + 화면 가장자리 비네트 (WL-813tj · 813j · #813)
//
// 기준서 v1 §D-1 813j 행: "HP 바 붉은 테두리(피격 0.3 s · UI 이미지 · 포스트 금지)"
// §B 요소 8 위험: 무적 해제 뒤 "맞고 있다"가 화면에 보여야 회복·회피·부활이 의미를 갖는다.
//
// ■ 이벤트 (구독만)
// WL.Combat.Core.CombatEvents.Damaged — 실측 발생 지점 `Assets/Script/Character/MyActor.cs:309`
// = **PC 전용**(MyActor)이라 몹 피격이 섞이지 않는다. 페이로드의 invincible 이 무적 분기를 알려준다.
// 🔴 Q1 QA 실측: playerInvincible=1 인 동안 PC 피격 2045회가 전부 dmg=0 으로 들어온다
// → 기본값 ignoreInvincibleHits=true 로 무적 피격을 버린다(813i 가 무적을 풀면 저절로 살아난다).
//
// ■ 붉은 테두리의 대상
// 실측 PC HP 바 = `Common/MyInfoUI/Slider_hp` (295×12 px · MyInfoUI 는 좌상단 (117.9, 44.9)).
// 813tj 가 Common 을 `WL_SafeArea_Back` 아래로 옮겼으므로 경로 탐색은 Safe Area 패널을 건너뛴다
// (WLVignetteUtil.FindUiPath). 테두리 노드는 **런타임에** 바 자식으로 만든다 —
// NewGameUI.prefab 에 굽지 않는다(ⓓ diff 최소 · 813c/813g 규칙).
//
// ■ 값 = WLSurvivalUiSettings.asset (C45) · 시간 = Time.unscaledTime(히트스톱 무관)
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Core;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class HitVignette : MonoBehaviour
{
public const string BorderNodeName = "WL_HitBorder";
[SerializeField] private RectTransform vignetteRoot;
private RectTransform _rt;
private Canvas _canvas;
private WLEdgeVignette _edge;
private WLEdgeVignette _border; // HP 바 둘레 4띠(단색)
private RectTransform _borderRoot;
private bool _subscribed;
private float _edgeStart = -1f, _borderStart = -1f, _lastHit = -999f;
// ── 진단 ──────────────────────────────────────────────────────────────
public int DamagedSeen { get; private set; }
public int InvincibleSkipped { get; private set; }
public int ThrottleSkipped { get; private set; }
public int FlashCount { get; private set; }
public string BorderTargetPath { get; private set; } = "";
public bool BorderBound { get { return _border != null && _border.IsBuilt; } }
public float EdgeAlpha { get { return _edge != null ? _edge.CurrentAlpha : 0f; } }
public float BorderAlpha { get { return _border != null ? _border.CurrentAlpha : 0f; } }
private void Awake() { _rt = GetComponent<RectTransform>(); }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// <summary>OnEnable 본체(에디트 모드 검증이 같은 경로를 탄다 · 813c 교훈). 테두리도 여기서 만든다.</summary>
public string Initialize()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
string border = EnsureBorder();
HideNow();
var s = WLSurvivalUiSettings.Instance;
Subscribe(s == null || s.hitFeedbackEnabled);
return "initialized subscribed=" + _subscribed + " · " + layout + " · " + border;
}
public void Subscribe(bool on)
{
if (on == _subscribed) return;
_subscribed = on;
if (on) CombatEvents.Damaged.Add(OnDamaged);
else CombatEvents.Damaged.Remove(OnDamaged);
}
// ── 구독 ──────────────────────────────────────────────────────────────
private void OnDamaged(in DamagedEvent e)
{
DamagedSeen++;
var s = WLSurvivalUiSettings.Instance;
if (s == null || !s.hitFeedbackEnabled) return;
if (e.invincible && s.ignoreInvincibleHits) { InvincibleSkipped++; return; }
float now = Time.unscaledTime;
if (now - _lastHit < s.hitMinIntervalSeconds) { ThrottleSkipped++; return; }
Flash(now);
}
/// <summary>피격 번쩍임 1회(테두리 + 가장자리). 프로브가 직접 부르기도 한다.</summary>
public string Flash(float now)
{
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 표시 안 함";
_lastHit = now;
FlashCount++;
_borderStart = s.hitBorderEnabled ? now : -1f;
_edgeStart = s.hitVignetteEnabled ? now : -1f;
if (_borderStart >= 0f && !BorderBound) EnsureBorder();
Tick(now);
if (s.verboseLog) Debug.Log("[HitVignette] flash border=" + s.hitBorderSeconds + "s edge=" + s.hitVignetteSeconds + "s");
return "flash border=" + (_borderStart >= 0f ? s.hitBorderSeconds.ToString("F2") + "s" : "off") +
" edge=" + (_edgeStart >= 0f ? s.hitVignetteSeconds.ToString("F2") + "s" : "off") +
" bound=" + BorderBound;
}
private void Update() { Tick(Time.unscaledTime); }
/// <summary>알파 감쇠 진행. 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
public void Tick(float now)
{
var s = WLSurvivalUiSettings.Instance;
if (s == null) return;
if (_borderStart >= 0f && _border != null && _border.IsBuilt)
{
float t = now - _borderStart;
if (t >= s.hitBorderSeconds) { _borderStart = -1f; _border.SetAlpha(s.hitBorderColor, 0f); }
else _border.SetAlpha(s.hitBorderColor, 1f - Mathf.Clamp01(t / s.hitBorderSeconds));
}
if (_edgeStart >= 0f && _edge != null && _edge.IsBuilt)
{
float t = now - _edgeStart;
if (t >= s.hitVignetteSeconds) { _edgeStart = -1f; _edge.SetAlpha(s.hitVignetteColor, 0f); }
else _edge.SetAlpha(s.hitVignetteColor, 1f - Mathf.Clamp01(t / s.hitVignetteSeconds));
}
}
public void HideNow()
{
_edgeStart = -1f; _borderStart = -1f;
var s = WLSurvivalUiSettings.Instance;
if (_edge != null && _edge.IsBuilt) _edge.SetAlpha(s != null ? s.hitVignetteColor : Color.red, 0f);
if (_border != null && _border.IsBuilt) _border.SetAlpha(s != null ? s.hitBorderColor : Color.red, 0f);
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
/// <summary>자기 노드 아래 가장자리 비네트만 만든다(HP 바 테두리는 EnsureBorder 가 런타임에).</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
_edge = WLVignetteUtil.Build(_rt, "Edge");
bool made = vignetteRoot == null;
if (vignetteRoot == null) vignetteRoot = _edge.root;
return made;
}
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 배치 건너뜀";
WLVignetteUtil.Stretch(_rt);
_rt.localScale = Vector3.one;
float u = UnitsPerPx();
if (_edge == null || !_edge.IsBuilt) _edge = WLVignetteUtil.Build(_rt, "Edge");
WLVignetteUtil.ApplyLayout(_edge, s.hitVignetteThicknessPx, u, s.hitVignetteColor);
if (vignetteRoot == null) vignetteRoot = _edge.root;
return "layout edgeThickness=" + (s.hitVignetteThicknessPx * u).ToString("F1") + " unitsPerPx=" + u.ToString("F4");
}
/// <summary>
/// HP 바를 찾아 그 둘레에 붉은 테두리 4띠를 만든다(런타임 전용 · 프리팹에 굽지 않는다).
/// Safe Area 재부모화 뒤에도 옛 경로 문자열이 그대로 먹도록 WLVignetteUtil.FindUiPath 를 쓴다.
/// </summary>
public string EnsureBorder()
{
var s = WLSurvivalUiSettings.Instance;
if (s == null || !s.hitBorderEnabled) return "테두리 off";
var canvasRoot = WLVignetteUtil.FindCanvasRoot(transform);
var target = WLVignetteUtil.FindUiPath(canvasRoot, s.hitBorderTargetPath) as Transform;
var targetRt = target as RectTransform;
if (targetRt == null)
{
BorderTargetPath = "(못 찾음) " + s.hitBorderTargetPath;
return "🔴 HP 바 경로 없음: " + s.hitBorderTargetPath + " — 테두리 생략(가장자리 비네트는 그대로)";
}
BorderTargetPath = GetPath(canvasRoot, targetRt);
float u = UnitsPerPx();
float t = Mathf.Max(0f, s.hitBorderThicknessPx) * u;
_border = WLVignetteUtil.Build(targetRt, BorderNodeName);
_borderRoot = _border.root;
if (_borderRoot != null)
{
// 바 바깥으로 두께만큼 벌린 사각형 → 그 안쪽 가장자리 4띠가 곧 테두리가 된다
_borderRoot.anchorMin = Vector2.zero; _borderRoot.anchorMax = Vector2.one;
_borderRoot.pivot = new Vector2(0.5f, 0.5f);
_borderRoot.offsetMin = new Vector2(-t, -t);
_borderRoot.offsetMax = new Vector2(t, t);
_borderRoot.localScale = Vector3.one;
}
WLVignetteUtil.ApplyLayout(_border, s.hitBorderThicknessPx, u, s.hitBorderColor);
// 테두리는 그라디언트가 아니라 단색이어야 "테두리" 로 읽힌다
for (int i = 0; i < _border.strips.Length; i++)
if (_border.strips[i] != null) _border.strips[i].sprite = null;
_border.SetAlpha(s.hitBorderColor, 0f);
return "테두리 부착 " + BorderTargetPath + " thickness=" + t.ToString("F1") + "u(" + s.hitBorderThicknessPx + "px)";
}
/// <summary>런타임에 만든 테두리 노드를 지운다(C8 롤백 · 프로브 정리용).</summary>
public string RemoveBorder()
{
if (_borderRoot == null)
{
var canvasRoot = WLVignetteUtil.FindCanvasRoot(transform);
var s = WLSurvivalUiSettings.Instance;
var target = s != null ? WLVignetteUtil.FindUiPath(canvasRoot, s.hitBorderTargetPath) : null;
if (target != null) _borderRoot = target.Find(BorderNodeName) as RectTransform;
}
if (_borderRoot == null) return "테두리 없음";
var go = _borderRoot.gameObject;
_borderRoot = null; _border = null;
if (Application.isPlaying) Destroy(go); else DestroyImmediate(go);
return "테두리 제거";
}
private static string GetPath(Transform root, Transform t)
{
var sb = new StringBuilder(t.name);
var cur = t.parent;
while (cur != null && cur != root) { sb.Insert(0, cur.name + "/"); cur = cur.parent; }
return sb.ToString();
}
public float UnitsPerPx()
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
return WLVignetteUtil.UnitsPerPx(_canvas);
}
// ── 검증 전용 ────────────────────────────────────────────────────────
/// <summary>가짜 PC 피격을 **실제 CombatEvents 경로로** 발행한다(Play 0 검증용).</summary>
public static string RaiseFakeDamaged(double damage, bool invincible)
{
var e = new DamagedEvent
{
victim = null, attacker = null, dinfo = null, damage = damage,
invincible = invincible, time = Time.unscaledTime, frame = Time.frameCount
};
CombatEvents.Damaged.Dispatch(in e);
return "dispatched Damaged(dmg=" + damage + " invincible=" + invincible + ") subscribers=" + CombatEvents.Damaged.Count;
}
public string Dump()
{
var sb = new StringBuilder();
var s = WLSurvivalUiSettings.Instance;
sb.AppendLine("[HitVignette] subscribed=" + _subscribed + " flashes=" + FlashCount +
" seen=" + DamagedSeen + " 무적스킵=" + InvincibleSkipped + " 스로틀스킵=" + ThrottleSkipped +
" edgeAlpha=" + EdgeAlpha.ToString("F3") + " borderAlpha=" + BorderAlpha.ToString("F3"));
sb.AppendLine(" 구독자 Damaged=" + CombatEvents.Damaged.Count + " Enabled=" + CombatEvents.Enabled);
sb.AppendLine(" 테두리 대상=" + (string.IsNullOrEmpty(BorderTargetPath) ? "(미탐색)" : BorderTargetPath) + " bound=" + BorderBound);
if (_borderRoot != null)
sb.AppendLine(" 테두리 root offMin" + _borderRoot.offsetMin + " offMax" + _borderRoot.offsetMax +
" rect=" + _borderRoot.rect.width.ToString("F1") + "x" + _borderRoot.rect.height.ToString("F1"));
if (_edge != null && _edge.IsBuilt)
for (int i = 0; i < _edge.strips.Length; i++)
{
var img = _edge.strips[i]; if (img == null) continue;
sb.AppendLine(" edge[" + img.name + "] size" + img.rectTransform.sizeDelta + " scale" + img.rectTransform.localScale +
" enabled=" + img.enabled + " a=" + img.color.a.ToString("F3") + " raycast=" + img.raycastTarget);
}
if (_border != null && _border.IsBuilt)
for (int i = 0; i < _border.strips.Length; i++)
{
var img = _border.strips[i]; if (img == null) continue;
sb.AppendLine(" border[" + img.name + "] size" + img.rectTransform.sizeDelta +
" enabled=" + img.enabled + " a=" + img.color.a.ToString("F3") +
" sprite=" + (img.sprite != null ? img.sprite.name : "(단색)"));
}
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"border=" + s.hitBorderSeconds + "s/" + s.hitBorderThicknessPx + "px edge=" + s.hitVignetteSeconds + "s/" +
s.hitVignetteThicknessPx + "px 무적무시=" + s.ignoreInvincibleHits + " 스로틀=" + s.hitMinIntervalSeconds + "s"));
return sb.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 863efecd89e890e448a36adf35578b68

View File

@ -0,0 +1,333 @@
// ─────────────────────────────────────────────────────────────────────────────
// PotionButton.cs — 전투 패드 물약 버튼(잔량 + 쿨 링) (WL-813tj · 813j · #813)
//
// 기준서 v1 §D-1 813j 행: "813c 의 빈 자리에 물약 버튼(잔량 · 쿨 링)"
// §B 요소 8: 물약 3개/런 · 40 % 회복 · 쿨 5 s.
//
// ■ 자리 (813c 소유)
// 813c 가 전투 패드 6슬롯 중 4를 스킬로 쓰고 **2를 예약**(물약 · 회피)으로 비활성화했다.
// 이 버튼은 그 예약 좌표(`WLHudLayoutSettings.ReserveSlotPx(index)`)에 **자기 노드**로 앉는다.
// 비활성 SkillCard 를 되살려 쓰지 않는 이유: `WLBattlePadLayout.Apply()` 가 매번 예약 슬롯을
// SetActive(false) 로 되돌리기 때문(813c 실측) — 켜 두면 두 코드가 서로 끈다.
//
// ■ 🔴 데이터는 813i(Gameplay) 가 주인 — 이 파일은 표시만 한다
// `WL.Combat.Survival.PotionUse` 는 **아직 없다**(브랜치 `wl/gameplay/WL-813i-survival` 은
// 2026-09-09 01:4x 기준 main 대비 커밋 0 · `Assets/WL/Combat/Survival/*.cs` 없음 = 실측).
// 존재하지 않는 타입을 참조하면 게이트가 깨지므로, **정적 델리게이트 자리**만 열어 둔다.
// 813i 는 자기 파일에서 아래 4개를 채우면 끝난다(UI 수정 0):
// PotionButton.CountProvider = () => PotionUse.Remain;
// PotionButton.CooldownRemainProvider = () => PotionUse.CooldownRemain;
// PotionButton.CooldownTotalProvider = () => PotionUse.CooldownTotal;
// PotionButton.UseHandler = () => PotionUse.TryUse();
// Provider 가 없으면 설정의 표시용 기본값(3개 · 5 s)을 그리고, 클릭은 무동작이다(오동작 방지).
//
// ■ 값 = WLSurvivalUiSettings.asset + 자리 = WLHudLayoutSettings.asset (둘 다 C45)
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class PotionButton : MonoBehaviour
{
// ── 813i 연결점 (「후속」 · 존재하지 않는 타입을 참조하지 않는다) ─────
/// <summary>남은 물약 개수. 813i 가 채운다.</summary>
public static System.Func<int> CountProvider;
/// <summary>남은 쿨(초). 813i 가 채운다.</summary>
public static System.Func<float> CooldownRemainProvider;
/// <summary>쿨 전체 길이(초). 813i 가 채운다.</summary>
public static System.Func<float> CooldownTotalProvider;
/// <summary>실제 사용. true = 소비 성공. 813i 가 채운다.</summary>
public static System.Func<bool> UseHandler;
/// <summary>버튼을 눌렀다는 통지(로그·튜토리얼용 · 선택).</summary>
public static System.Action UseRequested;
/// <summary>813i 가 연결됐는가(진단·완료보고 근거).</summary>
public static bool HasProvider { get { return CountProvider != null || UseHandler != null; } }
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private Image background;
[SerializeField] private Image cooldownRing;
[SerializeField] private TextMeshProUGUI iconLabel;
[SerializeField] private TextMeshProUGUI countLabel;
[SerializeField] private Button button;
[SerializeField] private CanvasGroup group;
[Header("폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset font;
private RectTransform _rt;
private Canvas _canvas;
private int _lastCount = -1;
private float _lastFill = -1f;
// ── 진단 ──────────────────────────────────────────────────────────────
public int ClickCount { get; private set; }
public int UsedCount { get; private set; }
public int BlockedCount { get; private set; }
public int ShownCount { get { return _lastCount; } }
public float ShownFill { get { return _lastFill; } }
private void Awake() { _rt = GetComponent<RectTransform>(); }
private void OnEnable() { Initialize(); }
public string Initialize()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
Refresh();
return "initialized · " + layout + " · provider=" + HasProvider;
}
// ── 값 읽기 (813i 있으면 그쪽 · 없으면 표시용 기본값) ────────────────
public int ReadCount()
{
if (CountProvider != null) { try { return CountProvider(); } catch { } }
var s = WLSurvivalUiSettings.Instance;
return s != null ? s.potionFallbackCount : 0;
}
public float ReadCooldownFill()
{
var s = WLSurvivalUiSettings.Instance;
float total = 0f, remain = 0f;
if (CooldownTotalProvider != null) { try { total = CooldownTotalProvider(); } catch { } }
if (CooldownRemainProvider != null) { try { remain = CooldownRemainProvider(); } catch { } }
if (total <= 0f) total = s != null ? s.potionFallbackCooldownSeconds : 0f;
if (total <= 0f) return 0f;
return Mathf.Clamp01(remain / total);
}
private void Update() { Refresh(); }
/// <summary>잔량·쿨 링·흐림을 현재 값에 맞춘다(값이 그대로면 아무 것도 하지 않는다).</summary>
public string Refresh()
{
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음";
int count = ReadCount();
float fill = ReadCooldownFill();
bool ready = count > 0 && fill <= 0.0001f;
if (count != _lastCount)
{
_lastCount = count;
if (countLabel != null) countLabel.text = string.Format(s.potionCountFormat, count);
}
if (!Mathf.Approximately(fill, _lastFill))
{
_lastFill = fill;
if (cooldownRing != null)
{
cooldownRing.fillAmount = fill;
if (cooldownRing.enabled != (fill > 0.0001f)) cooldownRing.enabled = fill > 0.0001f;
}
}
var tint = ready ? s.potionReadyColor : s.potionDimColor;
if (iconLabel != null) iconLabel.color = tint;
if (countLabel != null) countLabel.color = tint;
if (group != null) group.alpha = ready ? 1f : 0.75f;
return "count=" + count + " fill=" + fill.ToString("F3") + " ready=" + ready;
}
/// <summary>버튼 클릭. 813i Provider 가 없으면 아무 일도 하지 않는다(설정으로 완화 가능).</summary>
public string OnClickPotion()
{
ClickCount++;
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "설정 없음 — 무동작";
if (UseRequested != null) { try { UseRequested(); } catch { } }
if (UseHandler == null)
{
BlockedCount++;
if (s.verboseLog) Debug.Log("[PotionButton] 813i(PotionUse) 미연결 — 표시만 (후속)");
return s.potionRequireProvider ? "813i 미연결 — 무동작(후속)" : "813i 미연결 — 무동작";
}
bool ok = false;
try { ok = UseHandler(); } catch { ok = false; }
if (ok) UsedCount++; else BlockedCount++;
Refresh();
return ok ? "사용 성공" : "사용 불가(잔량 0 또는 쿨)";
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
bool made = false;
if (group == null) { group = GetComponent<CanvasGroup>(); if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; } }
if (background == null)
{
background = GetComponent<Image>();
if (background == null) { background = gameObject.AddComponent<Image>(); made = true; }
background.raycastTarget = true;
}
if (button == null)
{
button = GetComponent<Button>();
if (button == null) { button = gameObject.AddComponent<Button>(); made = true; }
button.targetGraphic = background;
button.onClick.RemoveListener(OnClickListener);
button.onClick.AddListener(OnClickListener);
}
if (cooldownRing == null)
{
var r = WLVignetteUtil.NewChild(_rt, "Ring");
WLVignetteUtil.Stretch(r);
cooldownRing = r.GetComponent<Image>();
if (cooldownRing == null) cooldownRing = r.gameObject.AddComponent<Image>();
cooldownRing.raycastTarget = false;
// 원본 SkillCard/Battle/i_cooltime 과 같은 규격(813c 실측: Filled · Radial360 · Top · 시계방향)
cooldownRing.type = Image.Type.Filled;
cooldownRing.fillMethod = Image.FillMethod.Radial360;
cooldownRing.fillOrigin = (int)Image.Origin360.Top;
cooldownRing.fillClockwise = true;
cooldownRing.fillAmount = 0f;
made = true;
}
if (iconLabel == null)
{
var t = WLVignetteUtil.NewChild(_rt, "Icon");
iconLabel = t.GetComponent<TextMeshProUGUI>();
if (iconLabel == null) iconLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
iconLabel.raycastTarget = false;
iconLabel.alignment = TextAlignmentOptions.Center;
if (font != null) iconLabel.font = font;
made = true;
}
if (countLabel == null)
{
var t = WLVignetteUtil.NewChild(_rt, "Count");
countLabel = t.GetComponent<TextMeshProUGUI>();
if (countLabel == null) countLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
countLabel.raycastTarget = false;
countLabel.alignment = TextAlignmentOptions.BottomRight;
if (font != null) countLabel.font = font;
made = true;
}
return made;
}
private void OnClickListener() { OnClickPotion(); }
/// <summary>813c 예약 슬롯 좌표에 버튼을 앉힌다(자리·크기의 주인은 WLHudLayoutSettings).</summary>
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLSurvivalUiSettings.Instance;
var hud = WLHudLayoutSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 배치 건너뜀";
if (hud == null) return "WLHudLayoutSettings 에셋 없음 — 배치 건너뜀";
bool on = s.potionButtonEnabled;
if (gameObject.activeSelf != on) gameObject.SetActive(on);
if (!on) return "potionButtonEnabled=false — 숨김";
float u = UnitsPerPx();
Vector2 px = hud.ReserveSlotPx(Mathf.Max(0, s.potionReserveSlotIndex));
WLBattlePadLayout.PlaceFromCorner(_rt, px, hud.reserveDiameterPx, u, hud.mirrorLeftHanded);
if (background != null) background.color = s.potionBgColor;
if (cooldownRing != null) cooldownRing.color = s.potionCooldownColor;
if (iconLabel != null)
{
var t = iconLabel.rectTransform;
WLVignetteUtil.Stretch(t);
iconLabel.fontSize = s.potionLabelFontPx * u;
iconLabel.text = s.potionLabelText;
if (font != null && iconLabel.font != font) iconLabel.font = font;
}
if (countLabel != null)
{
var t = countLabel.rectTransform;
WLVignetteUtil.Stretch(t);
t.offsetMin = new Vector2(0f, 0f);
t.offsetMax = new Vector2(-4f * u, -4f * u);
countLabel.fontSize = s.potionCountFontPx * u;
if (font != null && countLabel.font != font) countLabel.font = font;
}
_lastCount = -1; _lastFill = -1f; // 다음 Refresh 가 강제로 다시 그리게
return "배치 dx/dy px=" + px + " 지름=" + hud.reserveDiameterPx + "px pos" + _rt.anchoredPosition +
" size" + _rt.sizeDelta + " 앵커" + _rt.anchorMin + " unitsPerPx=" + u.ToString("F4");
}
public float UnitsPerPx()
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
return WLVignetteUtil.UnitsPerPx(_canvas);
}
public void SetFont(TMP_FontAsset f)
{
font = f;
if (f == null) return;
if (iconLabel != null) iconLabel.font = f;
if (countLabel != null) countLabel.font = f;
}
// ── 검증 전용 ────────────────────────────────────────────────────────
/// <summary>813i 대역(가짜 Provider) — 잔량·쿨·사용을 UI 만으로 왕복 검증한다.</summary>
public static string InstallFakeProvider(int count, float cooldownTotal)
{
int remain = count;
float cd = 0f;
CountProvider = () => remain;
CooldownTotalProvider = () => cooldownTotal;
CooldownRemainProvider = () => cd;
UseHandler = () =>
{
if (remain <= 0 || cd > 0f) return false;
remain--; cd = cooldownTotal; return true;
};
FakeCooldownSetter = v => cd = Mathf.Max(0f, v);
return "가짜 Provider 설치 count=" + count + " cd=" + cooldownTotal + "s (813i 대역)";
}
/// <summary>가짜 Provider 의 남은 쿨을 직접 흘린다(프로브가 시간을 대신 준다).</summary>
public static System.Action<float> FakeCooldownSetter;
public static string ClearProvider()
{
CountProvider = null; CooldownRemainProvider = null; CooldownTotalProvider = null;
UseHandler = null; UseRequested = null; FakeCooldownSetter = null;
return "Provider 해제(813i 미연결 상태로 복귀)";
}
public string Dump()
{
var sb = new StringBuilder();
var s = WLSurvivalUiSettings.Instance;
var hud = WLHudLayoutSettings.Instance;
sb.AppendLine("[PotionButton] provider=" + HasProvider + " clicks=" + ClickCount +
" used=" + UsedCount + " blocked=" + BlockedCount +
" 표시잔량=" + _lastCount + " 링fill=" + _lastFill.ToString("F3"));
if (_rt != null)
sb.AppendLine(" rect pos" + _rt.anchoredPosition + " size" + _rt.sizeDelta +
" aMin" + _rt.anchorMin + " aMax" + _rt.anchorMax + " scale" + _rt.localScale +
" active=" + gameObject.activeSelf);
if (cooldownRing != null)
sb.AppendLine(" ring type=" + cooldownRing.type + " method=" + cooldownRing.fillMethod +
" origin=" + cooldownRing.fillOrigin + " cw=" + cooldownRing.fillClockwise +
" fill=" + cooldownRing.fillAmount.ToString("F3") + " enabled=" + cooldownRing.enabled);
sb.AppendLine(" 라벨 icon=\"" + (iconLabel != null ? iconLabel.text : "-") + "\" count=\"" +
(countLabel != null ? countLabel.text : "-") + "\" 버튼=" + (button != null));
if (hud != null && s != null)
sb.AppendLine(" 자리 = 813c ReserveSlotPx(" + s.potionReserveSlotIndex + ")=" + hud.ReserveSlotPx(s.potionReserveSlotIndex) +
" 지름=" + hud.reserveDiameterPx + "px mirror=" + hud.mirrorLeftHanded);
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"enabled=" + s.potionButtonEnabled + " fallback(count=" + s.potionFallbackCount +
" cd=" + s.potionFallbackCooldownSeconds + "s) requireProvider=" + s.potionRequireProvider));
return sb.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64e4b2b6baa015840acbc47f37476e13

View File

@ -0,0 +1,343 @@
// ─────────────────────────────────────────────────────────────────────────────
// ReviveDialog.cs — 사망 시 부활 팝업 (WL-813tj · 813j · #813)
//
// 기준서 v1 §D-1 813j 행: "사망 시 부활 팝업(공용 팝업 SortOrder_5 재사용 ·
// '존 시작점에서 부활' · 비용 항목은 PD BM 전까지 무료)"
// §B 요소 8: 사망 연출 2 s → 부활(존 시작점 · 비용 = PD BM).
//
// ■ 🔴 사망 통지는 813i(Gameplay) 가 준다 — 실측 근거
// `CombatEvents.RaiseKilled` 의 호출처는 `Assets/Script/Character/Mob/MobActor.cs:435` **하나뿐**이라
// PC 사망은 어떤 811 이벤트로도 오지 않는다(2026-09-09 실측 · grep 전수).
// `wl/gameplay/WL-813i-survival` 브랜치는 main 대비 커밋 0 = `WL.Combat.Survival.DeathFlow` 도 없다.
// 그래서 존재하지 않는 타입을 참조하지 않고 **정적 진입점**만 열어 둔다(「후속」):
// WL.UI.ReviveDialog.NotifyDeath(); // 813i DeathFlow 가 사망 연출 뒤 1줄
// WL.UI.ReviveDialog.ReviveRequested = () => ...; // 813i 가 존 시작점 On_Regen 을 붙인다
// 813i 없이도 팝업은 "표시 + 확인" 까지 완전히 동작한다(부활 동작만 비어 있다).
//
// ■ 공용 팝업 재사용
// `Assets/Script/Info/Popup.cs`(싱글턴 `Popup.Ins` · Addressables 프리팹 `SortOrder_5` 안 · 802b 가
// 같은 프리팹에 SafeAreaFitter 를 붙였다)를 그대로 쓴다. 문구는 localtext 키가 없으므로
// `Set()` 직후 `label_msg.text` 를 설정 문자열로 덮는다(원본 Popup.cs **무수정**).
// 공용 팝업이 없거나(씬 미로드) 표가 없으면 자체 폴백 노드로 떨어진다.
//
// ■ 값 = WLSurvivalUiSettings.asset (C45) · 시간 = Time.unscaledTime
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class ReviveDialog : MonoBehaviour
{
// ── 813i 연결점 (「후속」) ────────────────────────────────────────────
/// <summary>확인을 누르면 불린다. 813i 가 존 시작점 부활을 붙인다.</summary>
public static System.Action ReviveRequested;
/// <summary>팝업이 뜰 때 불린다(진단·튜토리얼용 · 선택).</summary>
public static System.Action ReviveShown;
/// <summary>현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다).</summary>
public static ReviveDialog Active { get; private set; }
/// <summary>813i DeathFlow 가 사망 연출 뒤 부르는 진입점. 인스턴스가 없으면 무동작.</summary>
public static string NotifyDeath()
{
if (Active == null) return "ReviveDialog 인스턴스 없음 — 무동작";
return Active.QueueShow(Time.unscaledTime);
}
[Header("폴백 팝업 구성 요소 (공용 Popup 이 없을 때만 쓴다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform box;
[SerializeField] private Image dim;
[SerializeField] private Image boxBg;
[SerializeField] private TextMeshProUGUI messageLabel;
[SerializeField] private Button okButton;
[SerializeField] private TextMeshProUGUI okLabel;
[Header("폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset font;
private RectTransform _rt;
private Canvas _canvas;
private float _showAt = -1f;
// ── 진단 ──────────────────────────────────────────────────────────────
public int DeathSeen { get; private set; }
public int ShownCount { get; private set; }
public int ConfirmCount { get; private set; }
public bool UsedCommonPopup { get; private set; }
public string LastMessage { get; private set; } = "";
public bool FallbackVisible { get { return group != null && group.alpha > 0.001f; } }
public bool Pending { get { return _showAt >= 0f; } }
private void Awake() { _rt = GetComponent<RectTransform>(); Active = this; }
private void OnEnable() { Active = this; Initialize(); }
private void OnDisable() { if (Active == this) Active = null; }
public string Initialize()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
Active = this;
BuildIfNeeded();
string layout = ApplyLayout();
HideNow();
return "initialized · " + layout;
}
/// <summary>사망 통지 → 설정된 지연 뒤에 팝업을 연다(사망 연출 시간은 813i 소유).</summary>
public string QueueShow(float now)
{
DeathSeen++;
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 무동작";
if (!s.reviveEnabled) return "reviveEnabled=false — 무동작";
_showAt = now + Mathf.Max(0f, s.reviveDelaySeconds);
if (s.verboseLog) Debug.Log("[ReviveDialog] 사망 통지 — " + s.reviveDelaySeconds + "s 뒤 팝업");
return "예약 delay=" + s.reviveDelaySeconds.ToString("F2") + "s";
}
private void Update() { Tick(Time.unscaledTime); }
/// <summary>지연 만료 검사. 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
public void Tick(float now)
{
if (_showAt < 0f || now < _showAt) return;
_showAt = -1f;
ShowNow();
}
/// <summary>지금 바로 팝업을 연다(공용 Popup 우선 · 실패하면 폴백 노드).</summary>
public string ShowNow()
{
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음";
string msg = BuildMessage(s);
LastMessage = msg;
ShownCount++;
UsedCommonPopup = false;
if (s.reviveUseCommonPopup)
{
string r = TryCommonPopup(s, msg);
if (UsedCommonPopup)
{
if (ReviveShown != null) { try { ReviveShown(); } catch { } }
return r;
}
}
ShowFallback(s, msg);
if (ReviveShown != null) { try { ReviveShown(); } catch { } }
return "폴백 팝업 표시 msg=\"" + msg + "\"";
}
/// <summary>본문 = 설정 문자열(로컬라이즈되면 reviveMessageKey 우선) + 비용 줄.</summary>
public string BuildMessage(WLSurvivalUiSettings s)
{
string msg = s.reviveMessage;
if (s.reviveMessageKey > 0)
{
try { if (table_localtext.Ins != null) msg = table_localtext.Ins.Get_Text(s.reviveMessageKey); }
catch { }
}
if (s.reviveShowCostLine && !string.IsNullOrEmpty(s.reviveCostText))
msg += "\n" + s.reviveCostText;
return msg;
}
/// <summary>공용 Popup 싱글턴 재사용. 실패(미로드·표 없음)하면 UsedCommonPopup=false 로 남긴다.</summary>
private string TryCommonPopup(WLSurvivalUiSettings s, string msg)
{
try
{
if (Popup.Ins == null) return "공용 Popup 없음 — 폴백";
Popup.Ins.Set(ePopupType.One, s.reviveMessageKey, OnOkListener); // Action = void 시그니처
if (Popup.Ins.label_msg != null) Popup.Ins.label_msg.text = msg; // 문구는 SO 가 주인
UsedCommonPopup = true;
return "공용 Popup(SortOrder_5) 표시 msg=\"" + msg + "\"";
}
catch (System.Exception e)
{
UsedCommonPopup = false;
return "공용 Popup 실패(" + e.GetType().Name + ") — 폴백";
}
}
private void ShowFallback(WLSurvivalUiSettings s, string msg)
{
BuildIfNeeded();
ApplyLayout();
if (messageLabel != null) messageLabel.text = msg;
if (group != null) { group.alpha = 1f; group.blocksRaycasts = true; group.interactable = true; }
}
/// <summary>확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다.</summary>
public string OnConfirm()
{
ConfirmCount++;
HideNow();
if (ReviveRequested == null) return "확인 — 813i(ReviveRequested) 미연결이라 부활 동작 없음(후속)";
try { ReviveRequested(); } catch { }
return "확인 — 부활 요청 전달";
}
public void HideNow()
{
_showAt = -1f;
if (group == null) return;
group.alpha = 0f; group.blocksRaycasts = false; group.interactable = false;
}
// ── 구성 · 배치 (폴백 팝업) ──────────────────────────────────────────
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
bool made = false;
if (group == null) { group = GetComponent<CanvasGroup>(); if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; } }
if (dim == null)
{
var d = WLVignetteUtil.NewChild(_rt, "Dim");
WLVignetteUtil.Stretch(d);
dim = d.GetComponent<Image>();
if (dim == null) dim = d.gameObject.AddComponent<Image>();
dim.raycastTarget = true;
made = true;
}
if (box == null) { box = WLVignetteUtil.NewChild(_rt, "Box"); made = true; }
if (boxBg == null)
{
boxBg = box.GetComponent<Image>();
if (boxBg == null) { boxBg = box.gameObject.AddComponent<Image>(); made = true; }
boxBg.raycastTarget = true;
}
if (messageLabel == null)
{
var t = WLVignetteUtil.NewChild(box, "Msg");
messageLabel = t.GetComponent<TextMeshProUGUI>();
if (messageLabel == null) messageLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
messageLabel.raycastTarget = false;
messageLabel.alignment = TextAlignmentOptions.Center;
if (font != null) messageLabel.font = font;
made = true;
}
if (okButton == null)
{
var t = WLVignetteUtil.NewChild(box, "btn_ok");
var img = t.GetComponent<Image>();
if (img == null) img = t.gameObject.AddComponent<Image>();
img.raycastTarget = true;
okButton = t.GetComponent<Button>();
if (okButton == null) okButton = t.gameObject.AddComponent<Button>();
okButton.targetGraphic = img;
okButton.onClick.RemoveListener(OnOkListener);
okButton.onClick.AddListener(OnOkListener);
made = true;
}
if (okLabel == null)
{
var t = WLVignetteUtil.NewChild(okButton.GetComponent<RectTransform>(), "Label");
okLabel = t.GetComponent<TextMeshProUGUI>();
if (okLabel == null) okLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
okLabel.raycastTarget = false;
okLabel.alignment = TextAlignmentOptions.Center;
if (font != null) okLabel.font = font;
made = true;
}
return made;
}
private void OnOkListener() { OnConfirm(); }
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLSurvivalUiSettings.Instance;
if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 배치 건너뜀";
if (box == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = UnitsPerPx();
WLVignetteUtil.Stretch(_rt);
_rt.localScale = Vector3.one;
if (dim != null) dim.color = new Color(0f, 0f, 0f, 0.6f);
box.anchorMin = new Vector2(0.5f, 0.5f);
box.anchorMax = new Vector2(0.5f, 0.5f);
box.pivot = new Vector2(0.5f, 0.5f);
box.anchoredPosition = Vector2.zero;
box.sizeDelta = new Vector2(s.reviveFallbackSizePx.x * u, s.reviveFallbackSizePx.y * u);
if (boxBg != null) boxBg.color = s.reviveFallbackBgColor;
if (messageLabel != null)
{
var t = messageLabel.rectTransform;
t.anchorMin = new Vector2(0f, 0.35f); t.anchorMax = new Vector2(1f, 1f);
t.offsetMin = new Vector2(24f * u, 0f); t.offsetMax = new Vector2(-24f * u, -24f * u);
messageLabel.fontSize = s.reviveFallbackFontPx * u;
messageLabel.color = Color.white;
if (font != null && messageLabel.font != font) messageLabel.font = font;
}
if (okButton != null)
{
var t = okButton.GetComponent<RectTransform>();
t.anchorMin = new Vector2(0.5f, 0f); t.anchorMax = new Vector2(0.5f, 0f);
t.pivot = new Vector2(0.5f, 0f);
t.anchoredPosition = new Vector2(0f, 28f * u);
t.sizeDelta = new Vector2(320f * u, 96f * u);
var img = okButton.targetGraphic as Image;
if (img != null) img.color = new Color(0.137f, 0.906f, 0.529f, 1f);
}
if (okLabel != null)
{
WLVignetteUtil.Stretch(okLabel.rectTransform);
okLabel.text = s.reviveOkText;
okLabel.fontSize = s.reviveFallbackFontPx * u * 0.8f;
okLabel.color = new Color(0.04f, 0.09f, 0.06f, 1f);
if (font != null && okLabel.font != font) okLabel.font = font;
}
return "폴백 팝업 box size" + box.sizeDelta + " unitsPerPx=" + u.ToString("F4");
}
public float UnitsPerPx()
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
return WLVignetteUtil.UnitsPerPx(_canvas);
}
public void SetFont(TMP_FontAsset f)
{
font = f;
if (f == null) return;
if (messageLabel != null) messageLabel.font = f;
if (okLabel != null) okLabel.font = f;
}
// ── 검증 전용 ────────────────────────────────────────────────────────
public string Dump()
{
var sb = new StringBuilder();
var s = WLSurvivalUiSettings.Instance;
sb.AppendLine("[ReviveDialog] deaths=" + DeathSeen + " shows=" + ShownCount + " confirms=" + ConfirmCount +
" 대기중=" + Pending + " 공용팝업사용=" + UsedCommonPopup +
" 폴백표시=" + FallbackVisible + " 813i(ReviveRequested)=" + (ReviveRequested != null));
sb.AppendLine(" 본문=\"" + LastMessage.Replace("\n", " / ") + "\"");
sb.AppendLine(" 공용 Popup.Ins=" + (Popup.Ins != null ? "있음" : "없음(에디트 모드/씬 미로드)"));
if (box != null)
sb.AppendLine(" 폴백 box size" + box.sizeDelta + " pos" + box.anchoredPosition +
" ok=\"" + (okLabel != null ? okLabel.text : "-") + "\"");
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"enabled=" + s.reviveEnabled + " 공용팝업=" + s.reviveUseCommonPopup +
" delay=" + s.reviveDelaySeconds + "s key=" + s.reviveMessageKey +
" 비용=\"" + s.reviveCostText + "\""));
return sb.ToString();
}
}
}

View File

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

View File

@ -144,13 +144,20 @@ namespace WL.UI
LastMaxReachPx = maxReach;
LastMinGapPx = MinGapPx(s, centers, active, total);
// 813tj 물약 버튼 — 예약 슬롯 자리에 있으므로 미러·해상도가 바뀌면 같이 다시 앉힌다.
var potion = GetComponentInChildren<PotionButton>(true);
if (potion != null) potion.ApplyLayout();
return "applied slots=" + active + "/" + total + " unitsPerPx=" + u.ToString("F4") +
" maxReachPx=" + maxReach.ToString("F1") + " minGapPx=" + LastMinGapPx.ToString("F1") +
" mirror=" + s.mirrorLeftHanded;
}
/// <summary>모서리(우하단 · 미러면 좌하단) 기준 (dx, dy) px 로 배치한다.</summary>
private static void PlaceFromCorner(RectTransform rt, Vector2 px, float diameterPx, float unitsPerPx, bool mirror)
/// <summary>
/// 모서리(우하단 · 미러면 좌하단) 기준 (dx, dy) px 로 배치한다.
/// 813tj 물약 버튼(PotionButton)이 예약 슬롯 자리에 앉을 때 **같은 산식**을 쓰도록 public 이다.
/// </summary>
public static void PlaceFromCorner(RectTransform rt, Vector2 px, float diameterPx, float unitsPerPx, bool mirror)
{
Vector2 anchor = new Vector2(mirror ? 0f : 1f, 0f);
rt.anchorMin = anchor;

View File

@ -122,25 +122,15 @@ namespace WL.UI
}
// ── 유틸 ──────────────────────────────────────────────────────────────
/// <summary>비활성 자식까지 따라가는 경로 탐색("A/B/C"). Transform.Find 와 달리 구분자 공백을 허용한다.</summary>
/// <summary>
/// 비활성 자식까지 따라가는 경로 탐색("A/B/C"). Transform.Find 와 달리 구분자 공백을 허용한다.
/// 813tj 가 NewGameUI 캔버스 직속 5레이어를 `WL_SafeArea_*` 패널 아래로 옮겼으므로,
/// 한 단계를 못 찾으면 Safe Area 패널을 한 겹 건너뛰고 다시 본다
/// (설정 에셋의 옛 경로 문자열 "MessageInfo/Chat" 이 그대로 살아 있어야 한다 = 회귀 0).
/// </summary>
private static Transform FindByPath(Transform root, string path)
{
if (root == null || string.IsNullOrEmpty(path)) return null;
var cur = root;
var parts = path.Split('/');
for (int i = 0; i < parts.Length; i++)
{
var name = parts[i].Trim();
if (name.Length == 0) continue;
Transform next = null;
for (int c = 0; c < cur.childCount; c++)
{
if (cur.GetChild(c).name == name) { next = cur.GetChild(c); break; }
}
if (next == null) return null;
cur = next;
}
return cur;
return WLVignetteUtil.FindUiPath(root, path);
}
// ── 검증·디버그 전용 ──────────────────────────────────────────────────

View File

@ -0,0 +1,245 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLVignetteUtil.cs — 화면 가장자리 UI 비네트 조립 헬퍼 + Safe Area 내성 경로 탐색
// (WL-813tj · 813t/813j 공용 · #813)
//
// ■ 왜 UI 이미지인가
// 기준서 §D-1 813t·813j 가 "포스트 프로세싱 금지 → UI 이미지" 를 못 박았다.
// URP Volume(Vignette)은 모바일에서 풀스크린 패스를 하나 더 만들고, 이 프로젝트는 UI 캔버스가 주인이다.
//
// ■ 왜 풀스크린 이미지가 아니라 "가장자리 4띠" 인가 (모바일 필레이트)
// 반투명 풀스크린 쿼드 = 화면 전체 블렌드 1패스. 실제로 보이는 것은 가장자리뿐이므로
// 상·하·좌·우 4개 띠(두께 = 설정 px)만 그린다 → 덮는 면적이 두께에 비례해서만 는다.
// 부드러운 감쇠는 **런타임 생성 그라디언트 스프라이트**(32×4 / 4×32)로 낸다 — 아트 에셋 의존 0.
// 코너는 가로띠와 세로띠가 겹쳐 자연히 진해진다(비네트로서 옳은 방향).
//
// ■ 좌표 규약
// 좌 띠 = 좌측 세로 스트레치(폭 T) · 우 띠 = 같은 것을 localScale.x = 1 로 뒤집음
// 하 띠 = 하단 가로 스트레치(높이 T) · 상 띠 = localScale.y = 1
// → 스프라이트는 2장(가로 램프 · 세로 램프)만 있으면 되고, 회전이 없어 rect 가 그대로 잰다.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.UI;
namespace WL.UI
{
/// <summary>가장자리 비네트 4띠 묶음. 만들기·크기 맞추기·알파 넣기만 한다(수명 관리는 호출부).</summary>
public sealed class WLEdgeVignette
{
public RectTransform root;
public readonly Image[] strips = new Image[4]; // 0 좌 · 1 우 · 2 하 · 3 상
public bool IsBuilt { get { return root != null && strips[0] != null && strips[3] != null; } }
/// <summary>알파 0~1 을 4띠에 한 번에 넣는다. 0 이면 Image 를 꺼서 드로우콜까지 없앤다.</summary>
public void SetAlpha(Color baseColor, float t)
{
float a = baseColor.a * Mathf.Clamp01(t);
bool on = a > 0.001f;
for (int i = 0; i < strips.Length; i++)
{
var img = strips[i];
if (img == null) continue;
if (img.enabled != on) img.enabled = on;
// 꺼도 색까지 0 으로 내린다 — 덤프가 옛 알파를 남겨 오해를 부르지 않게.
img.color = new Color(baseColor.r, baseColor.g, baseColor.b, on ? a : 0f);
}
}
/// <summary>현재 첫 띠의 알파(진단용).</summary>
public float CurrentAlpha { get { return strips[0] != null && strips[0].enabled ? strips[0].color.a : 0f; } }
}
public static class WLVignetteUtil
{
public const int UILayer = 5;
// ── 런타임 생성 그라디언트 스프라이트(에셋 의존 0 · 정적 캐시) ────────
private static Sprite s_hRamp; // u=0 에서 알파 1 → u=1 에서 0
private static Sprite s_vRamp; // v=0 에서 알파 1 → v=1 에서 0
/// <summary>가로 램프(왼쪽이 진하다). 좌/우 띠가 쓴다.</summary>
public static Sprite HorizontalRamp { get { if (s_hRamp == null) s_hRamp = BuildRamp(32, 4, true); return s_hRamp; } }
/// <summary>세로 램프(아래가 진하다). 하/상 띠가 쓴다.</summary>
public static Sprite VerticalRamp { get { if (s_vRamp == null) s_vRamp = BuildRamp(4, 32, false); return s_vRamp; } }
private static Sprite BuildRamp(int w, int h, bool horizontal)
{
var tex = new Texture2D(w, h, TextureFormat.RGBA32, false);
tex.name = "WL_VignetteRamp_" + (horizontal ? "H" : "V");
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
tex.hideFlags = HideFlags.DontSave;
int n = horizontal ? w : h;
var px = new Color32[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float t = (n <= 1) ? 0f : (float)(horizontal ? x : y) / (n - 1);
// (1-t)^2 = 바깥이 진하고 안쪽으로 빠르게 사라지는 감쇠(선형보다 덜 답답하다)
float a = (1f - t); a *= a;
px[y * w + x] = new Color32(255, 255, 255, (byte)Mathf.RoundToInt(Mathf.Clamp01(a) * 255f));
}
tex.SetPixels32(px);
tex.Apply(false, false);
var sp = Sprite.Create(tex, new Rect(0, 0, w, h), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect);
sp.name = tex.name;
sp.hideFlags = HideFlags.DontSave;
return sp;
}
// ── 조립 ──────────────────────────────────────────────────────────────
/// <summary>부모 아래에 비네트 4띠를 만든다(이미 있으면 재사용). 루트는 전체 스트레치.</summary>
public static WLEdgeVignette Build(RectTransform parent, string rootName)
{
var v = new WLEdgeVignette();
if (parent == null) return v;
var rootT = parent.Find(rootName) as RectTransform;
if (rootT == null)
{
var go = new GameObject(rootName, typeof(RectTransform));
go.layer = parent.gameObject.layer;
rootT = (RectTransform)go.transform;
rootT.SetParent(parent, false);
}
Stretch(rootT);
rootT.localScale = Vector3.one;
v.root = rootT;
v.strips[0] = EnsureStrip(rootT, "Left");
v.strips[1] = EnsureStrip(rootT, "Right");
v.strips[2] = EnsureStrip(rootT, "Bottom");
v.strips[3] = EnsureStrip(rootT, "Top");
return v;
}
private static Image EnsureStrip(RectTransform parent, string childName)
{
var t = parent.Find(childName) as RectTransform;
if (t == null)
{
var go = new GameObject(childName, typeof(RectTransform));
go.layer = parent.gameObject.layer;
t = (RectTransform)go.transform;
t.SetParent(parent, false);
}
var img = t.GetComponent<Image>();
if (img == null) img = t.gameObject.AddComponent<Image>();
img.raycastTarget = false; // 비네트는 절대 입력을 먹지 않는다
img.type = Image.Type.Simple;
img.preserveAspect = false;
return img;
}
/// <summary>두께(px)·색을 4띠에 적용한다. unitsPerPx = 813c WLHudLayoutSettings.UnitsPerPx.</summary>
public static void ApplyLayout(WLEdgeVignette v, float thicknessPx, float unitsPerPx, Color color)
{
if (v == null || !v.IsBuilt) return;
float t = Mathf.Max(0f, thicknessPx) * unitsPerPx;
Place(v.strips[0].rectTransform, new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 0.5f), new Vector2(t, 0f), new Vector3(1f, 1f, 1f));
Place(v.strips[1].rectTransform, new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(1f, 0.5f), new Vector2(t, 0f), new Vector3(-1f, 1f, 1f));
Place(v.strips[2].rectTransform, new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, t), new Vector3(1f, 1f, 1f));
Place(v.strips[3].rectTransform, new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, t), new Vector3(1f, -1f, 1f));
v.strips[0].sprite = HorizontalRamp;
v.strips[1].sprite = HorizontalRamp;
v.strips[2].sprite = VerticalRamp;
v.strips[3].sprite = VerticalRamp;
v.SetAlpha(color, 0f); // 만들자마자 보이지 않게
}
private static void Place(RectTransform rt, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 size, Vector3 scale)
{
rt.anchorMin = aMin; rt.anchorMax = aMax; rt.pivot = pivot;
rt.anchoredPosition = Vector2.zero;
rt.sizeDelta = size;
rt.localScale = scale;
}
public static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
rt.pivot = new Vector2(0.5f, 0.5f);
}
/// <summary>RectTransform 자식 하나를 만들거나 찾는다(공통 조립용).</summary>
public static RectTransform NewChild(RectTransform parent, string childName)
{
var t = parent != null ? parent.Find(childName) as RectTransform : null;
if (t != null) { t.localScale = Vector3.one; return t; }
var go = new GameObject(childName, typeof(RectTransform));
go.layer = parent != null ? parent.gameObject.layer : UILayer;
t = (RectTransform)go.transform;
t.SetParent(parent, false);
t.localScale = Vector3.one;
return t;
}
// ── Safe Area 내성 경로 탐색 (WL-813tj Safe Area 재부모화 회귀 방지) ──
/// <summary>
/// "A/B/C" 경로를 찾되, 한 단계가 없으면 **이름이 safeAreaPrefix 로 시작하는 자식 아래**에서 한 번 더 본다.
/// 813tj 가 NewGameUI 캔버스 직속 5레이어를 WL_SafeArea_* 패널 아래로 옮겼기 때문에,
/// 옛 경로 문자열(예: "MessageInfo/Chat" · "Common/MyInfoUI/Slider_hp")이 그대로 살아 있어야 한다.
/// 비활성 자식도 따라간다(Transform.Find 와 달리 구분자 공백 허용).
/// </summary>
public const string SafeAreaPrefix = "WL_SafeArea";
public static Transform FindUiPath(Transform root, string path)
{
if (root == null || string.IsNullOrEmpty(path)) return null;
var parts = path.Split('/');
var cur = root;
for (int i = 0; i < parts.Length; i++)
{
var name = parts[i].Trim();
if (name.Length == 0) continue;
var next = FindDirectChild(cur, name);
if (next == null)
{
// Safe Area 패널을 한 겹 건너뛰고 다시 본다(패널은 순수 컨테이너라 경로에 없다).
for (int c = 0; c < cur.childCount && next == null; c++)
{
var panel = cur.GetChild(c);
if (panel.name.StartsWith(SafeAreaPrefix)) next = FindDirectChild(panel, name);
}
}
if (next == null) return null;
cur = next;
}
return cur;
}
private static Transform FindDirectChild(Transform parent, string name)
{
for (int c = 0; c < parent.childCount; c++)
if (parent.GetChild(c).name == name) return parent.GetChild(c);
return null;
}
/// <summary>이 UI 트리의 캔버스 루트(NewGameUI)를 찾는다.</summary>
public static Transform FindCanvasRoot(Transform any)
{
if (any == null) return null;
var canvas = any.GetComponentInParent<Canvas>(true);
if (canvas != null) return canvas.transform;
var cur = any;
while (cur.parent != null) cur = cur.parent;
return cur;
}
/// <summary>813c 와 같은 px→유닛 계수. 설정 에셋이 없으면 1(배치를 건너뛰라는 뜻).</summary>
public static float UnitsPerPx(Canvas canvas)
{
var s = WLHudLayoutSettings.Instance;
return s != null ? s.UnitsPerPx(canvas) : 1f;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 856fb9c1634e6b141971990a7dd870ec

View File

@ -0,0 +1,37 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 859bca962dac74a4088853e4883ebf1a, type: 3}
m_Name: WLBossUiSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLBossUiSettings
bannerEnabled: 1
retriggerGuardSeconds: 3
bannerSeconds: 1.2
bannerFadeInSeconds: 0.15
bannerFadeOutSeconds: 0.25
bannerPunchScale: 1.35
bannerPunchSeconds: 0.18
bannerTopMarginPx: 300
bannerSizePx: {x: 860, y: 150}
bannerBgColor: {r: 0, g: 0, b: 0, a: 0.55}
bannerTitleText: BOSS
bannerTitleFontPx: 96
bannerTitleColor: {r: 1, g: 0.271, b: 0.278, a: 1}
bannerNameFontPx: 56
bannerNameColor: {r: 1, g: 0.941, b: 0.722, a: 1}
hideNameWhenUnknown: 1
warnVignetteEnabled: 1
warnVignetteSeconds: 0.8
warnVignetteFadeInSeconds: 0.12
warnVignettePulses: 2
warnVignetteThicknessPx: 180
warnVignetteColor: {r: 1, g: 0.165, b: 0.165, a: 0.55}
verboseLog: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 69179b8c8e5047247826746dd7cfe621
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5257aa4ad66ee1444b67fe4556b6fc7b, type: 3}
m_Name: WLSurvivalUiSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLSurvivalUiSettings
potionButtonEnabled: 1
potionReserveSlotIndex: 0
potionLabelText: "\uBB3C\uC57D"
potionLabelFontPx: 30
potionCountFormat: '{0}'
potionCountFontPx: 34
potionBgColor: {r: 0.129, g: 0.145, b: 0.176, a: 0.85}
potionReadyColor: {r: 0.137, g: 0.906, b: 0.529, a: 1}
potionDimColor: {r: 0.651, g: 0.651, b: 0.655, a: 1}
potionCooldownColor: {r: 0, g: 0, b: 0, a: 0.65}
potionFallbackCount: 3
potionFallbackCooldownSeconds: 5
potionRequireProvider: 1
reviveEnabled: 1
reviveUseCommonPopup: 1
reviveDelaySeconds: 2
reviveMessage: "\uC874 \uC2DC\uC791\uC810\uC5D0\uC11C \uBD80\uD65C\uD569\uB2C8\uB2E4."
reviveMessageKey: 0
reviveCostText: "\uBB34\uB8CC"
reviveShowCostLine: 1
reviveOkText: "\uBD80\uD65C"
reviveFallbackSizePx: {x: 760, y: 380}
reviveFallbackBgColor: {r: 0.06, g: 0.07, b: 0.09, a: 0.92}
reviveFallbackFontPx: 44
hitFeedbackEnabled: 1
ignoreInvincibleHits: 1
hitMinIntervalSeconds: 0.15
hitBorderEnabled: 1
hitBorderSeconds: 0.3
hitBorderThicknessPx: 8
hitBorderColor: {r: 1, g: 0.271, b: 0.278, a: 0.95}
hitBorderTargetPath: Common/MyInfoUI/Slider_hp
hitVignetteEnabled: 1
hitVignetteSeconds: 0.3
hitVignetteThicknessPx: 140
hitVignetteColor: {r: 1, g: 0.165, b: 0.165, a: 0.35}
verboseLog: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: abe956fdb6206c847b1891264720f46b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,111 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLBossUiSettings.cs — 보스 등장 연출(배너 + 경고 비네트) 값의 단일 출처 (WL-813tj · 813t · #813)
//
// 기준서 v1 §D-1 813t 행: "Spawned(isBoss) → 상단 BOSS 배너 1.2 s + 화면 가장자리 붉은 UI 비네트 0.8 s
// (포스트 프로세싱 금지 → UI 이미지) · 보스 이름은 MonsterList.n_MonsterName"
// §B 요소 6 "등장 · 처치 연출": 등장 배너 + 카메라 오빗 1.2 s + 상단 HP 바.
//
// ■ 왜 이 에셋인가 (C45)
// 시간·크기·색·문구를 코드 상수로 두지 않는다. 이 파일에는 "필드와 기본값"만 있고,
// 게임이 실제로 읽는 값은 Resources/WL/WLBossUiSettings.asset 이다.
// 에셋이 없으면 Instance == null → BossBanner 는 아무 것도 만들지 않는다(C8 롤백 1순위).
//
// ■ px → 캔버스 유닛
// 환산 계수를 여기에 두지 않는다. 813c 의 WLHudLayoutSettings.UnitsPerPx(canvas) 하나만 쓴다
// (802b·813c·813g 실측 1.7778 = 세로 1080 기준). 산식이 두 벌이 되면 값이 갈린다.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.UI
{
[CreateAssetMenu(fileName = "WLBossUiSettings", menuName = "WL/Boss UI Settings", order = 31)]
public sealed class WLBossUiSettings : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLBossUiSettings";
private static WLBossUiSettings s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 연출을 건너뛴다 = C8 롤백).</summary>
public static WLBossUiSettings Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLBossUiSettings>(ResourcesPath);
}
return s_instance;
}
}
public static void ClearCache() { s_instance = null; s_tried = false; }
// ── 공통 스위치 ───────────────────────────────────────────────────────
[Header("공통")]
[Tooltip("끄면 BossBanner 가 구독조차 하지 않는다(원본 100% · C8 롤백)")]
public bool bannerEnabled = true;
[Tooltip("같은 보스가 다시 Spawned 를 내도 이 시간 안이면 배너를 다시 띄우지 않는다(중복 방지)")]
public float retriggerGuardSeconds = 3f;
// ── 배너 ──────────────────────────────────────────────────────────────
[Header("배너 (기준서 §D-1 813t · 1.2 s)")]
[Tooltip("배너 총 표시 시간 — 기준서 1.2")]
public float bannerSeconds = 1.2f;
[Tooltip("등장 페이드 인")]
public float bannerFadeInSeconds = 0.15f;
[Tooltip("퇴장 페이드 아웃")]
public float bannerFadeOutSeconds = 0.25f;
[Tooltip("배너 등장 펀치 배율 (813g 텍스트 연출과 같은 계열)")]
public float bannerPunchScale = 1.35f;
[Tooltip("펀치가 원래 크기로 수렴하는 시간")]
public float bannerPunchSeconds = 0.18f;
[Tooltip("Safe Area 상단에서 배너 위 모서리까지 (px) — 813c 보스 HP 바(190~256 px)와 겹치지 않는 값")]
public float bannerTopMarginPx = 300f;
[Tooltip("배너 크기 (px · 세로 1080 기준)")]
public Vector2 bannerSizePx = new Vector2(860f, 150f);
[Tooltip("배너 뒷판 색 (알파 0 이면 뒷판을 만들지 않는다)")]
public Color bannerBgColor = new Color(0f, 0f, 0f, 0.55f);
[Tooltip("큰 글씨 — 문구도 값이다(로컬라이즈 전까지 여기서 바꾼다)")]
public string bannerTitleText = "BOSS";
[Tooltip("큰 글씨 크기 (px)")]
public float bannerTitleFontPx = 96f;
[Tooltip("큰 글씨 색 — 기준서 §B 등급 5 빨강 팔레트")]
public Color bannerTitleColor = new Color(1f, 0.271f, 0.278f, 1f); // #FD4547
[Tooltip("보스 이름 줄 크기 (px) · 이름은 MonsterList.n_MonsterName(localtext)에서 온다")]
public float bannerNameFontPx = 56f;
[Tooltip("보스 이름 줄 색")]
public Color bannerNameColor = new Color(1f, 0.941f, 0.722f, 1f); // #FFF0B8
[Tooltip("이름을 못 구했을 때 이름 줄을 숨긴다(빈 줄 방지)")]
public bool hideNameWhenUnknown = true;
// ── 경고 비네트 (UI 이미지 · 포스트 프로세싱 금지) ────────────────────
[Header("경고 비네트 (기준서 §D-1 813t · 0.8 s · UI 이미지)")]
[Tooltip("끄면 비네트를 만들지 않는다")]
public bool warnVignetteEnabled = true;
[Tooltip("비네트 총 표시 시간 — 기준서 0.8")]
public float warnVignetteSeconds = 0.8f;
[Tooltip("이 시간 안에 최고 밝기까지 올린다")]
public float warnVignetteFadeInSeconds = 0.12f;
[Tooltip("표시 시간 안에서 몇 번 맥동하는가 (1 = 한 번 밝아졌다 꺼짐)")]
public int warnVignettePulses = 2;
[Tooltip("가장자리 띠 두께 (px) — 9-슬라이스 테두리라 화면 중앙은 그리지 않는다(fillCenter=false)")]
public float warnVignetteThicknessPx = 180f;
[Tooltip("비네트 색(알파 = 최고 밝기)")]
public Color warnVignetteColor = new Color(1f, 0.165f, 0.165f, 0.55f); // #FF2A2A · a 0.55
// ── 진단 ──────────────────────────────────────────────────────────────
[Header("진단")]
[Tooltip("배너·비네트 전이를 콘솔에 남긴다(Play 진단용 · 기본 꺼짐)")]
public bool verboseLog = false;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 859bca962dac74a4088853e4883ebf1a

View File

@ -0,0 +1,143 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSurvivalUiSettings.cs — 생존 UI(물약 버튼 · 부활 팝업 · 피격 테두리) 값의 단일 출처
// (WL-813tj · 813j · #813)
//
// 기준서 v1 §D-1 813j 행: "813c 의 빈 자리에 물약 버튼(잔량 · 쿨 링) · 사망 시 부활 팝업
// (공용 팝업 SortOrder_5 재사용 · '존 시작점에서 부활' · 비용 항목은 PD BM 전까지 무료)
// · HP 바 붉은 테두리(피격 0.3 s · UI 이미지 · 포스트 금지)"
// §B 요소 8: 물약 3개/런 · 40 % 회복 · 쿨 5 s · 사망 연출 2 s → 부활(존 시작점).
//
// ■ 데이터의 주인 (🔴 중요)
// 물약 개수·회복률·쿨·사망 연출 시간의 **진짜 주인은 813i(Gameplay)의 WLSurvivalSettings** 다.
// 이 파일의 같은 이름 값은 **813i 가 아직 없을 때 UI 가 무엇을 그릴지**를 정하는 표시용 기본값이다
// (813i 병합 후에는 아래 Provider 델리게이트가 채워지고 이 기본값은 쓰이지 않는다 — PotionButton 참조).
//
// ■ px → 캔버스 유닛
// 813c 의 WLHudLayoutSettings.UnitsPerPx(canvas) 하나만 쓴다(산식 중복 0).
// 물약 버튼 좌표도 여기 두지 않는다 — 813c 의 예약 슬롯(ReserveSlotPx)이 자리의 주인이다.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.UI
{
[CreateAssetMenu(fileName = "WLSurvivalUiSettings", menuName = "WL/Survival UI Settings", order = 32)]
public sealed class WLSurvivalUiSettings : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLSurvivalUiSettings";
private static WLSurvivalUiSettings s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 전부 건너뛴다 = C8 롤백).</summary>
public static WLSurvivalUiSettings Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLSurvivalUiSettings>(ResourcesPath);
}
return s_instance;
}
}
public static void ClearCache() { s_instance = null; s_tried = false; }
// ── 물약 버튼 ─────────────────────────────────────────────────────────
[Header("물약 버튼 (813c 예약 슬롯 자리)")]
[Tooltip("끄면 버튼을 만들지도 배치하지도 않는다(원본 100% · C8 롤백)")]
public bool potionButtonEnabled = true;
[Tooltip("813c 예약 슬롯 인덱스 — 0 = 물약 자리, 1 = 회피 자리(813r)")]
public int potionReserveSlotIndex = 0;
[Tooltip("아이콘 스프라이트가 붙기 전까지 쓸 글자(아트 확정 시 스프라이트로 교체)")]
public string potionLabelText = "물약";
[Tooltip("아이콘 글자 크기 (px)")]
public float potionLabelFontPx = 30f;
[Tooltip("잔량 표기 형식 — {0} = 남은 개수")]
public string potionCountFormat = "{0}";
[Tooltip("잔량 글자 크기 (px)")]
public float potionCountFontPx = 34f;
[Tooltip("버튼 바탕 색")]
public Color potionBgColor = new Color(0.129f, 0.145f, 0.176f, 0.85f);
[Tooltip("사용 가능할 때 테두리·글자 색")]
public Color potionReadyColor = new Color(0.137f, 0.906f, 0.529f, 1f); // #23E787
[Tooltip("잔량 0 또는 쿨 중일 때 색")]
public Color potionDimColor = new Color(0.651f, 0.651f, 0.655f, 1f); // #A6A6A7
[Tooltip("쿨 링 색 — 원본 SkillCard 의 i_cooltime 과 같은 계열")]
public Color potionCooldownColor = new Color(0f, 0f, 0f, 0.65f);
[Header("물약 표시용 기본값 (🔴 진짜 값의 주인은 813i · WLSurvivalSettings)")]
[Tooltip("813i 가 없을 때 표시할 잔량 — 기준서 §B 요소 8 = 3개/런")]
public int potionFallbackCount = 3;
[Tooltip("813i 가 없을 때 표시할 쿨 길이(초) — 기준서 §B 요소 8 = 5 s")]
public float potionFallbackCooldownSeconds = 5f;
[Tooltip("813i Provider 가 연결되기 전에는 버튼을 눌러도 아무 일도 하지 않는다(오동작 방지)")]
public bool potionRequireProvider = true;
// ── 부활 팝업 ─────────────────────────────────────────────────────────
[Header("부활 팝업 (공용 팝업 Popup/SortOrder_5 재사용)")]
[Tooltip("끄면 사망 통지를 받아도 팝업을 띄우지 않는다")]
public bool reviveEnabled = true;
[Tooltip("공용 Popup 싱글턴을 재사용한다(끄면 자체 노드로 폴백 — 공용 팝업이 없는 씬 대비)")]
public bool reviveUseCommonPopup = true;
[Tooltip("사망 통지 → 팝업까지 지연(초) — 기준서 §B 요소 8 사망 연출 2 s. 연출 자체는 813i 소유")]
public float reviveDelaySeconds = 2f;
[Tooltip("팝업 본문 — localtext 키가 없어 문구를 값으로 둔다(로컬라이즈되면 reviveMessageKey 사용)")]
[TextArea(1, 3)]
public string reviveMessage = "존 시작점에서 부활합니다.";
[Tooltip("0 보다 크면 이 localtext 키를 우선 사용한다(로컬라이즈 이후)")]
public int reviveMessageKey = 0;
[Tooltip("비용 표기 — PD BM 결정 전까지 무료(기준서 §D-1 813j)")]
public string reviveCostText = "무료";
[Tooltip("비용 줄을 본문 아래에 덧붙인다")]
public bool reviveShowCostLine = true;
[Tooltip("폴백 팝업(공용 팝업이 없을 때)의 확인 버튼 글자")]
public string reviveOkText = "부활";
[Tooltip("폴백 팝업 크기 (px)")]
public Vector2 reviveFallbackSizePx = new Vector2(760f, 380f);
[Tooltip("폴백 팝업 뒷판 색")]
public Color reviveFallbackBgColor = new Color(0.06f, 0.07f, 0.09f, 0.92f);
[Tooltip("폴백 팝업 글자 크기 (px)")]
public float reviveFallbackFontPx = 44f;
// ── 피격 테두리 · 비네트 ──────────────────────────────────────────────
[Header("피격 연출 (Damaged 구독 · UI 이미지 · 포스트 금지)")]
[Tooltip("끄면 Damaged 를 구독하지 않는다")]
public bool hitFeedbackEnabled = true;
[Tooltip("🔴 무적 피격(playerInvincible 1)은 무시한다 — Q1 QA 실측 2045회 전부 dmg=0 이었다")]
public bool ignoreInvincibleHits = true;
[Tooltip("연속 피격 스로틀(초) — 자동전투 다단 히트에서 깜빡임 폭주를 막는다")]
public float hitMinIntervalSeconds = 0.15f;
[Tooltip("HP 바 붉은 테두리를 그린다 — 기준서 §D-1 813j")]
public bool hitBorderEnabled = true;
[Tooltip("테두리 유지 시간(초) — 기준서 0.3")]
public float hitBorderSeconds = 0.3f;
[Tooltip("테두리 두께 (px)")]
public float hitBorderThicknessPx = 8f;
[Tooltip("테두리 색")]
public Color hitBorderColor = new Color(1f, 0.271f, 0.278f, 0.95f); // #FD4547
[Tooltip("붉은 테두리를 두를 대상 경로(NewGameUI 루트 기준) — 실측 Common/MyInfoUI/Slider_hp (295×12 px). " +
"Safe Area 재부모화 뒤에도 WL_SafeArea_* 를 건너뛰고 찾는다")]
public string hitBorderTargetPath = "Common/MyInfoUI/Slider_hp";
[Tooltip("화면 가장자리 피격 비네트도 함께 번쩍인다")]
public bool hitVignetteEnabled = true;
[Tooltip("피격 비네트 유지 시간(초)")]
public float hitVignetteSeconds = 0.3f;
[Tooltip("피격 비네트 두께 (px)")]
public float hitVignetteThicknessPx = 140f;
[Tooltip("피격 비네트 색(알파 = 최고 밝기) — 경고 비네트보다 옅게")]
public Color hitVignetteColor = new Color(1f, 0.165f, 0.165f, 0.35f);
// ── 진단 ──────────────────────────────────────────────────────────────
[Header("진단")]
[Tooltip("피격·물약·부활 전이를 콘솔에 남긴다(기본 꺼짐)")]
public bool verboseLog = false;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5257aa4ad66ee1444b67fe4556b6fc7b