Merge branch 'wl/gameplay/WL-813z-death-freeze-hotfix'

This commit is contained in:
깃 관리자 2026-09-09 09:47:56 +09:00
commit 3ba1c4c51b
8 changed files with 855 additions and 9 deletions

View File

@ -0,0 +1,554 @@
// WL-813z 프로브 — Q4-a 핫픽스 실측(에디트 모드 · Play 0 · #813).
// run_script --file AgentScripts/WL813z_Probe.cs --entry WL813z_Probe.<Entry>
// Watchdog · Topmost · Toast · All
// 로그는 <worktree>/Logs/WL813z_*.txt (Assets 밖 · 미추적).
//
// 🔴 에디트 모드에서 `Time.timeScale` 에 **쓰지 않는다** — 쓰면 ProjectSettings/TimeManager.asset 이 더러워진다
// (811b-fix 실측 · DeathFlow.HoldWorld 도 같은 이유로 `Application.isPlaying` 가드를 둔다).
// 세계 정지 복원은 ① `DeathFlow.WorldHeld` 전이 ② `MyValue.Get_GameSpeed()` 실측값 ③ `Time.timeScale` 읽기로 증명한다.
// 🔴 씬·프리팹을 저장하지 않는다. 만든 노드는 finally 에서 전부 DestroyImmediate.
// 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복한다(AssetDatabase.SaveAssets 호출 0).
using System;
using System.IO;
using System.Reflection;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using WL.Combat.Survival;
using WL.UI;
public static class WL813z_Probe
{
const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic;
// ─────────────────────────────────────────────────────────── 공통
static string LogDir
{
get
{
string d = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Logs");
if (!Directory.Exists(d)) Directory.CreateDirectory(d);
return d;
}
}
static string Write(string name, string body)
{
string p = Path.Combine(LogDir, name);
File.WriteAllText(p, body, new UTF8Encoding(false));
return p;
}
static string Ok(bool b) { return b ? "PASS" : "**FAIL**"; }
/// <summary>세로 1080×1920 기준(참조 1920×1080 Expand)의 합성 캔버스 + Safe Area 부모.</summary>
static RectTransform BuildCanvas(out GameObject root, out Canvas canvas, out float unitsPerPx)
{
root = new GameObject("__WL813z_Canvas", typeof(RectTransform));
root.hideFlags = HideFlags.DontSave;
canvas = root.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = root.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f); // 원본 NewGameUI 와 같은 설정(802b 실측 1.7778)
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
root.AddComponent<GraphicRaycaster>();
var s = WLCombatTextSettings.Instance;
unitsPerPx = s != null ? s.UnitsPerPx(canvas) : 1f;
// WL_HUD 대역(SafeAreaFitter 아래 = Safe Area) — 배치가 화면 크기에 흔들리지 않게 크기를 못박는다.
var hud = new GameObject("WL_HUD", typeof(RectTransform)).GetComponent<RectTransform>();
hud.gameObject.hideFlags = HideFlags.DontSave;
hud.SetParent(root.transform, false);
hud.anchorMin = hud.anchorMax = new Vector2(0.5f, 0.5f);
hud.pivot = new Vector2(0.5f, 0.5f);
hud.anchoredPosition = Vector2.zero;
hud.sizeDelta = new Vector2(1080f * unitsPerPx, 1920f * unitsPerPx);
return hud;
}
static RectTransform Decoy(RectTransform parent, string n)
{
var rt = new GameObject(n, typeof(RectTransform)).GetComponent<RectTransform>();
rt.gameObject.hideFlags = HideFlags.DontSave;
rt.SetParent(parent, false);
return rt;
}
/// <summary>813y 프로브와 같은 가짜 메인 PC(사망 상태). 원본 회복 경로는 ReviveOverride 로 막는다.</summary>
static PCActor MakePC()
{
var go = new GameObject("__WL813z_PC");
go.hideFlags = HideFlags.DontSave;
var pc = go.AddComponent<PCActor>();
pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None;
var stat = new ActorStatInfo(eRole.PC);
stat.Set_Stat(eStat.MaxHP, 1000d); stat.Set_Stat(eStat.HP, 0d);
typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat);
typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false);
typeof(Actor).GetField("DeadStatus", NP).SetValue(pc, true);
return pc;
}
static void OnReq(in ReviveRequestEvent e) { s_reqSeen++; }
static int s_reqSeen;
static void Kill(GameObject go) { if (go != null) UnityEngine.Object.DestroyImmediate(go); }
// ─────────────────────────────────────────────── ① D-3 부활 워치독
public static string Watchdog()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813z ① D-3 부활 워치독 (에디트 모드 · Play 0) ===");
var st = WLSurvivalSettings.Instance;
if (st == null) { var b0 = sb.AppendLine("**FAIL** WLSurvivalSettings.asset 없음").ToString(); return b0 + "\n→ " + Write("WL813z_watchdog.txt", b0); }
float ts0 = Time.timeScale;
int ov0 = WLSurvivalSettings.RuntimeOverride;
float wd0 = st.reviveWatchdogSeconds; bool ig0 = st.reviveWatchdogIgnoresDisable; bool lg0 = st.reviveWatchdogLog;
float to0 = st.autoReviveTimeoutSeconds; bool an0 = st.autoReviveWhenNoListener; bool hw0 = st.holdWorldWhileReviving;
var ovr0 = DeathFlow.ReviveOverride;
int overrideCalls = 0;
PCActor pc = null;
sb.AppendLine("에셋 값 실측: reviveWatchdogSeconds=" + wd0.ToString("F1") + "s · 무시disable=" + ig0 +
" · log=" + lg0 + " · autoReviveTimeoutSeconds=" + to0.ToString("F1") + "s · holdWorld=" + hw0);
sb.AppendLine("세계 복원값 실측: MyValue.Get_GameSpeed() = " + MyValue.Get_GameSpeed().ToString("F2") +
" · 시작 Time.timeScale = " + ts0.ToString("F2") +
" (에디트 모드에선 DeathFlow.HoldWorld 가 isPlaying 가드로 0 을 쓰지 않는다 = TimeManager.asset 무변경)");
sb.AppendLine();
try
{
WLSurvivalSettings.RuntimeOverride = 1;
DeathFlow.ReviveOverride = (p, r) => { overrideCalls++; };
s_reqSeen = 0;
DeathFlow.ReviveRequested.Add(OnReq); // 구독자 1 = autoReviveWhenNoListener 무력화(813y 와 같은 조건)
pc = MakePC();
float disp = st.deathDisplaySeconds;
// ── A. 기본 20 s 워치독
st.reviveWatchdogSeconds = 20f; st.reviveWatchdogIgnoresDisable = true; st.reviveWatchdogLog = true;
DeathFlow.ResetDiagnostics();
float t = 1000f;
DeathFlow.Tick(t);
DeathFlow.OnPCDied(pc);
sb.AppendLine("[A] 20 s 워치독 (정상 · 스위치 ON)");
sb.AppendLine(" 사망 → state=" + DeathFlow.State + " deathCount=" + DeathFlow.DeathCount);
float req = t + disp + 0.01f;
DeathFlow.Tick(req);
sb.AppendLine(" +" + disp.ToString("F2") + "s → state=" + DeathFlow.State + " · ReviveRequested 수신=" + s_reqSeen +
" · 구독자=" + DeathFlow.ReviveRequested.Count + " · WorldHeld=" + DeathFlow.WorldHeld);
DeathFlow.Tick(req + 19.9f);
sb.AppendLine(" +19.9s(팝업이 에러 모달에 가려 확인 못 누른 상태) → state=" + DeathFlow.State +
" 대기=" + DeathFlow.AwaitingSeconds.ToString("F2") + "s · 워치독=" + DeathFlow.WatchdogCount + "회 · " +
Ok(DeathFlow.State == DeathState.AwaitingRevive && DeathFlow.WatchdogCount == 0));
DeathFlow.Tick(req + 20.02f);
bool a = DeathFlow.State == DeathState.None && DeathFlow.WatchdogCount == 1 && DeathFlow.AutoReviveCount == 1
&& DeathFlow.ReviveCount == 1 && !DeathFlow.WorldHeld && overrideCalls == 1;
sb.AppendLine(" +20.02s → state=" + DeathFlow.State + " · 워치독=" + DeathFlow.WatchdogCount + "회(" +
DeathFlow.LastWatchdogSeconds.ToString("F2") + "s) · auto=" + DeathFlow.AutoReviveCount +
" · reviveCount=" + DeathFlow.ReviveCount + " · 부활실행=" + overrideCalls +
" · WorldHeld=" + DeathFlow.WorldHeld + " · timeScale=" + Time.timeScale.ToString("F2") + " · " + Ok(a));
sb.AppendLine();
// ── B. RuntimeDisabled 무관 (도중에 스위치가 꺼져도 워치독이 돈다)
DeathFlow.ResetDiagnostics(); overrideCalls = 0; s_reqSeen = 0;
t = 2000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
req = t + disp + 0.01f; DeathFlow.Tick(req);
WLSurvivalSettings.RuntimeOverride = -1; // = QA 의 RuntimeDisabled
sb.AppendLine("[B] 대기 중 RuntimeDisabled(-1) · reviveWatchdogIgnoresDisable=true");
sb.AppendLine(" Enabled=" + WLSurvivalSettings.Enabled + " state=" + DeathFlow.State);
DeathFlow.Tick(req + 20.02f);
bool bb = DeathFlow.State == DeathState.None && DeathFlow.WatchdogCount == 1 && DeathFlow.AbortCount == 0;
sb.AppendLine(" +20.02s → state=" + DeathFlow.State + " · 워치독=" + DeathFlow.WatchdogCount + "회 · abort=" +
DeathFlow.AbortCount + " · 부활실행=" + overrideCalls + " · WorldHeld=" + DeathFlow.WorldHeld + " · " + Ok(bb));
// ── B'. 무시disable=false 면 워치독 대신 Abort — 이때도 세계 정지는 풀린다(잠금 0)
st.reviveWatchdogIgnoresDisable = false;
DeathFlow.ResetDiagnostics(); overrideCalls = 0;
WLSurvivalSettings.RuntimeOverride = 1;
t = 3000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
req = t + disp + 0.01f; DeathFlow.Tick(req);
WLSurvivalSettings.RuntimeOverride = -1;
DeathFlow.Tick(req + 0.02f);
bool bp = DeathFlow.State == DeathState.None && DeathFlow.AbortCount == 1 && !DeathFlow.WorldHeld;
sb.AppendLine(" 무시disable=false → 대신 Abort(\"" + DeathFlow.LastAbortReason + "\") · state=" + DeathFlow.State +
" · WorldHeld=" + DeathFlow.WorldHeld + " (ReleaseWorld 통과 = 잠금 0) · " + Ok(bp));
st.reviveWatchdogIgnoresDisable = true;
WLSurvivalSettings.RuntimeOverride = 1;
sb.AppendLine();
// ── C. C8 롤백 — reviveWatchdogSeconds = 0 이면 워치독이 없다(813i 원래 동작 100%)
st.reviveWatchdogSeconds = 0f;
DeathFlow.ResetDiagnostics(); overrideCalls = 0;
t = 4000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
req = t + disp + 0.01f; DeathFlow.Tick(req);
DeathFlow.Tick(req + 999f);
bool c = DeathFlow.State == DeathState.AwaitingRevive && DeathFlow.WatchdogCount == 0;
sb.AppendLine("[C] C8 롤백 reviveWatchdogSeconds=0 → +999s 뒤에도 state=" + DeathFlow.State +
" · 워치독=" + DeathFlow.WatchdogCount + "회 · " + Ok(c) + " (= 813i 원래 동작)");
DeathFlow.Abort("probe cleanup");
sb.AppendLine();
// ── D. 정상 확인 버튼 경로가 워치독보다 먼저면 워치독은 안 돈다
st.reviveWatchdogSeconds = 20f;
DeathFlow.ResetDiagnostics(); overrideCalls = 0;
t = 5000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
req = t + disp + 0.01f; DeathFlow.Tick(req);
DeathFlow.Tick(req + 3f);
bool revived = DeathFlow.Revive();
bool d = revived && DeathFlow.State == DeathState.None && DeathFlow.WatchdogCount == 0 && DeathFlow.AutoReviveCount == 0;
sb.AppendLine("[D] 3 s 뒤 확인 버튼(DeathFlow.Revive()) → " + revived + " · state=" + DeathFlow.State +
" · 워치독=" + DeathFlow.WatchdogCount + "회 · auto=" + DeathFlow.AutoReviveCount + " · " + Ok(d));
}
catch (Exception ex) { sb.AppendLine("**예외** " + ex); }
finally
{
DeathFlow.ReviveRequested.Remove(OnReq);
DeathFlow.Abort("probe finally");
DeathFlow.ReviveOverride = ovr0;
DeathFlow.ResetDiagnostics();
st.reviveWatchdogSeconds = wd0; st.reviveWatchdogIgnoresDisable = ig0; st.reviveWatchdogLog = lg0;
st.autoReviveTimeoutSeconds = to0; st.autoReviveWhenNoListener = an0; st.holdWorldWhileReviving = hw0;
WLSurvivalSettings.RuntimeOverride = ov0;
if (pc != null) Kill(pc.gameObject);
}
sb.AppendLine();
sb.AppendLine("정리: state=" + DeathFlow.State + " · WorldHeld=" + DeathFlow.WorldHeld +
" · 구독 Req=" + DeathFlow.ReviveRequested.Count +
" · timeScale " + ts0.ToString("F2") + " → " + Time.timeScale.ToString("F2") +
" (에디트 모드 쓰기 0) · SO 원복 완료");
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_watchdog.txt", body);
}
// ─────────────────────────────────── ② 부활 팝업 최상단 보장 + WL_btn_ok
public static string Topmost()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813z ② 부활 팝업 최상단 보장(D-3) + WL_btn_ok(D-9) ===");
var st = WLSurvivalSettings.Instance;
var us = WLSurvivalUiSettings.Instance;
if (st == null || us == null)
{
var b0 = sb.AppendLine("**FAIL** WLSurvivalSettings/WLSurvivalUiSettings 에셋 없음").ToString();
return b0 + "\n→ " + Write("WL813z_topmost.txt", b0);
}
bool tm0 = st.reviveTopmostEnabled; int so0 = st.reviveSortingOrder;
float iv0 = st.reviveTopmostIntervalSeconds; bool es0 = st.reviveEscalateOnHijack;
GameObject root = null;
try
{
Canvas canvas; float u;
var hud = BuildCanvas(out root, out canvas, out u);
sb.AppendLine("합성 캔버스: unitsPerPx=" + u.ToString("F4") + " · WL_HUD size=" + hud.sizeDelta +
" · 공용 팝업 프리팹 SortOrder_5 의 Canvas.m_SortingOrder = 5 (실측 · Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab:9159)");
sb.AppendLine("에셋 값: reviveTopmostEnabled=" + tm0 + " · reviveSortingOrder=" + so0 +
" · 재보장 주기=" + iv0.ToString("F2") + "s · 승격=" + es0);
sb.AppendLine();
Decoy(hud, "기존팝업_A");
var dlgRt = Decoy(hud, "WL_ReviveDialog");
Decoy(hud, "기존팝업_B");
var dlg = dlgRt.gameObject.AddComponent<ReviveDialog>();
sb.AppendLine("[A] 표시 순간 최상단 · 레이캐스트");
sb.AppendLine(" init → " + dlg.Initialize().Split('·')[0].Trim());
sb.AppendLine(" 표시 전: 맨뒤형제=" + dlg.IsLastSibling + " sortingOrder=" + dlg.SortingOrder);
string shown = dlg.ShowNow();
var cv = dlgRt.GetComponent<Canvas>();
var gr = dlgRt.GetComponent<GraphicRaycaster>();
var cg = dlgRt.GetComponent<CanvasGroup>();
bool a = dlg.IsLastSibling && cv != null && cv.overrideSorting && cv.sortingOrder == st.reviveSortingOrder
&& cv.sortingOrder > 5 && gr != null && gr.enabled && cg != null && cg.blocksRaycasts;
sb.AppendLine(" ShowNow → " + shown);
sb.AppendLine(" 맨뒤형제=" + dlg.IsLastSibling + " · overrideSorting=" + (cv != null && cv.overrideSorting) +
" · sortingOrder=" + (cv != null ? cv.sortingOrder : -1) + "(> 공용 5) · GraphicRaycaster=" +
(gr != null && gr.enabled) + " · CanvasGroup.blocksRaycasts=" + (cg != null && cg.blocksRaycasts) +
" · " + Ok(a));
sb.AppendLine();
sb.AppendLine("[B] 표시 중 " + iv0.ToString("F2") + "s 마다 최상단 재보장");
float now = Time.unscaledTime;
Decoy(hud, "나중에_뜬_에러팝업"); // 부활 팝업 위로 올라온다
bool before = dlg.IsLastSibling;
int tc0 = dlg.TopmostCount;
dlg.Tick(now + iv0 + 0.01f);
bool b = !before && dlg.IsLastSibling && dlg.TopmostCount > tc0;
sb.AppendLine(" 에러 팝업이 뒤에 붙음 → 맨뒤형제=" + before + " → Tick(+" + (iv0 + 0.01f).ToString("F2") +
"s) → 맨뒤형제=" + dlg.IsLastSibling + " · 재보장 " + tc0 + "→" + dlg.TopmostCount + "회 · " + Ok(b));
sb.AppendLine();
sb.AppendLine("[C] 재보장 루프 GC 0");
for (int i = 0; i < 20; i++) dlg.Tick(now + 100f + i * (iv0 + 0.01f)); // 워밍업(첫 AddComponent 배제)
long m0 = GC.GetTotalMemory(true);
int n = 600;
for (int i = 0; i < n; i++) dlg.Tick(now + 200f + i * (iv0 + 0.01f));
long m1 = GC.GetTotalMemory(false);
long d = m1 - m0;
sb.AppendLine(" Tick ×" + n + "(전부 재보장 실행) → GC.GetTotalMemory 델타 = " + d + " B · 재보장 누계 " +
dlg.TopmostCount + "회 · " + Ok(d <= 0));
sb.AppendLine();
sb.AppendLine("[D] D-9 런타임 버튼 이름");
var okName = ReviveDialog.OkButtonName;
Transform found = null, old = null;
foreach (var t in dlgRt.GetComponentsInChildren<Transform>(true))
{
if (t.name == okName) found = t;
if (t.name == "btn_ok") old = t;
}
bool dd = found != null && found.GetComponent<Button>() != null && old == null && okName == "WL_btn_ok";
sb.AppendLine(" ReviveDialog.OkButtonName=\"" + okName + "\" · 노드 존재=" + (found != null) +
" · Button=" + (found != null && found.GetComponent<Button>() != null) +
" · 옛 \"btn_ok\" 노드=" + (old != null ? old.name : "없음") + " · " + Ok(dd));
sb.AppendLine(" → QA 절차: `LightProbe.ClickButton [\"WL_btn_ok\"]` (원본 `NightmareResult/btn_ok` 와 이름 충돌 0)");
sb.AppendLine();
sb.AppendLine("[E] 공용 Popup 탈취 → 전용 팝업 승격");
dlg.HideNow();
sb.AppendLine(" " + dlg.ProbeSimulateCommonPopup("존 시작점에서 부활합니다.\n무료"));
sb.AppendLine(" (에디트 모드는 Popup.Ins=" + (Popup.Ins != null ? "있음" : "없음") + " → '공용 팝업이 사라졌다/덮였다'와 같은 조건)");
string r = dlg.ProbeCheckHijack();
bool e = dlg.HijackCount == 1 && dlg.Escalated && dlg.FallbackVisible && dlg.IsLastSibling && !dlg.UsedCommonPopup;
sb.AppendLine(" " + r + " · " + Ok(e));
sb.AppendLine();
sb.AppendLine("[F] C8 롤백");
st.reviveTopmostEnabled = false;
var dlg2Rt = Decoy(hud, "WL_ReviveDialog_C8");
var dlg2 = dlg2Rt.gameObject.AddComponent<ReviveDialog>();
dlg2.Initialize();
Decoy(hud, "C8_뒤형제");
dlg2.ShowNow();
bool f1 = dlg2Rt.GetComponent<Canvas>() == null && !dlg2.IsLastSibling;
sb.AppendLine(" reviveTopmostEnabled=0 → Canvas 부착=" + (dlg2Rt.GetComponent<Canvas>() != null) +
" · 맨뒤형제=" + dlg2.IsLastSibling + " · " + Ok(f1) + " (813y 동작 100%)");
st.reviveTopmostEnabled = tm0;
st.reviveEscalateOnHijack = false;
dlg2.HideNow();
dlg2.ProbeSimulateCommonPopup("존 시작점에서 부활합니다.");
int hj0 = dlg2.HijackCount;
dlg2.ProbeCheckHijack();
bool f2 = dlg2.HijackCount == hj0 && !dlg2.Escalated;
sb.AppendLine(" reviveEscalateOnHijack=0 → 탈취감지 " + hj0 + "→" + dlg2.HijackCount + " · 승격=" + dlg2.Escalated + " · " + Ok(f2));
st.reviveEscalateOnHijack = es0;
dlg2.HideNow();
sb.AppendLine();
// ── G. 공용 Popup 싱글턴을 가짜로 세워 D-3 사슬 전체를 재현한다.
// (에디트 모드엔 Popup·BackKeyMgr 인스턴스가 없다 — 둘 다 public static 필드라 직접 세운다.)
sb.AppendLine("[G] 공용 Popup 실물 재현 — 탈취 감지 / 남의 팝업 불간섭 / QA-4 닫힘");
var popup0 = Popup.Ins; var bk0 = BackKeyMgr.Ins;
GameObject fakeBk = null, fakePopupGo = null;
try
{
fakeBk = new GameObject("__WL813z_BackKeyMgr"); fakeBk.hideFlags = HideFlags.DontSave;
BackKeyMgr.Ins = fakeBk.AddComponent<BackKeyMgr>();
fakePopupGo = new GameObject("__WL813z_Popup"); fakePopupGo.hideFlags = HideFlags.DontSave;
var fp = fakePopupGo.AddComponent<Popup>();
var popBody = new GameObject("m_popup"); popBody.hideFlags = HideFlags.DontSave; popBody.transform.SetParent(fakePopupGo.transform, false);
var msgGo = new GameObject("label_msg", typeof(RectTransform)); msgGo.hideFlags = HideFlags.DontSave;
msgGo.transform.SetParent(popBody.transform, false);
fp.m_popup = popBody;
fp.label_msg = msgGo.AddComponent<TextMeshProUGUI>();
Popup.Ins = fp;
const string mine = "존 시작점에서 부활합니다.\n무료";
// G-1. 우리 본문이 그대로면 탈취 아님(오탐 0)
dlg.HideNow();
dlg.ProbeSimulateCommonPopup(mine);
popBody.SetActive(true); fp.label_msg.text = mine;
int hj = dlg.HijackCount;
dlg.ProbeCheckHijack();
bool g1 = dlg.HijackCount == hj && !dlg.Escalated && dlg.UsedCommonPopup;
sb.AppendLine(" G-1 본문 그대로 → 탈취감지 " + hj + "→" + dlg.HijackCount + " · 승격=" + dlg.Escalated + " · " + Ok(g1) + " (오탐 0)");
// G-2. QA-4 — 부활이 끝나면 공용 Popup 도 닫는다(우리 것일 때만)
int closed0 = dlg.CommonPopupClosed;
dlg.HideNow();
bool g2 = !popBody.activeSelf && dlg.CommonPopupClosed == closed0 + 1;
sb.AppendLine(" G-2 HideNow(부활 완료) → 공용 Popup active=" + popBody.activeSelf + " · 우리가 닫음 " +
closed0 + "→" + dlg.CommonPopupClosed + " · " + Ok(g2) + " (Q4-a QA-4 해소)");
// G-3. ErrorLogHookManager 가 같은 싱글턴을 덮어쓴 상태 = D-3 그 자체
dlg.ProbeSimulateCommonPopup(mine);
popBody.SetActive(true); fp.label_msg.text = mine;
fp.label_msg.text = "NullReferenceException: Object reference not set …"; // = ErrorLogHookManager.cs:33 이 덮어쓴 뒤
int hj2 = dlg.HijackCount;
dlg.ProbeCheckHijack();
bool g3 = dlg.HijackCount == hj2 + 1 && dlg.Escalated && dlg.FallbackVisible && dlg.IsLastSibling && !dlg.UsedCommonPopup;
sb.AppendLine(" G-3 에러 모달이 덮음(본문 교체 = m_ok 도 null 로 지워진 상태) → 탈취감지 " + hj2 + "→" + dlg.HijackCount +
" · 승격=" + dlg.Escalated + " · 전용 팝업 표시=" + dlg.FallbackVisible +
" · 맨뒤형제=" + dlg.IsLastSibling + " · sortingOrder=" + dlg.SortingOrder + " · " + Ok(g3));
// G-4. 남의 팝업은 닫지 않는다
int closed1 = dlg.CommonPopupClosed;
dlg.HideNow();
bool g4 = popBody.activeSelf && dlg.CommonPopupClosed == closed1;
sb.AppendLine(" G-4 남의(에러) 팝업 → HideNow 후에도 active=" + popBody.activeSelf + " · 닫은 수 " +
closed1 + "→" + dlg.CommonPopupClosed + " · " + Ok(g4) + " (불간섭)");
}
finally
{
Popup.Ins = popup0; BackKeyMgr.Ins = bk0;
Kill(fakePopupGo); Kill(fakeBk);
}
dlg.HideNow();
}
catch (Exception ex) { sb.AppendLine("**예외** " + ex); }
finally
{
st.reviveTopmostEnabled = tm0; st.reviveSortingOrder = so0;
st.reviveTopmostIntervalSeconds = iv0; st.reviveEscalateOnHijack = es0;
Kill(root);
}
sb.AppendLine("정리: 합성 노드 파기 · SO 원복 · 씬/프리팹 저장 0");
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_topmost.txt", body);
}
// ─────────────────────────────────── ③ D-7 전리품 토스트 Safe Area 클램프
public static string Toast()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813z ③ D-7 전리품 토스트 Safe Area 클램프 ===");
var s = WLCombatTextSettings.Instance;
if (s == null) { var b0 = sb.AppendLine("**FAIL** WLCombatTextSettings.asset 없음").ToString(); return b0 + "\n→ " + Write("WL813z_toast.txt", b0); }
bool cl0 = s.lootToastClampToSafeArea; float mw0 = s.lootToastMaxWidthPx;
float sp0 = s.lootToastSafePadPx; bool el0 = s.lootToastEllipsis;
GameObject root = null;
try
{
Canvas canvas; float u;
var hud = BuildCanvas(out root, out canvas, out u);
var toastRt = Decoy(hud, "WL_LootToast");
var toast = toastRt.gameObject.AddComponent<LootToast>();
sb.AppendLine("합성 Safe Area: 폭 1080 px(=" + (1080f * u).ToString("F1") + " 유닛) · unitsPerPx=" + u.ToString("F4"));
sb.AppendLine("에셋 값: widthPx=" + s.lootToastWidthPx.ToString("F0") + " · rightMarginPx=" + s.lootToastRightMarginPx.ToString("F0") +
" · slideInPx=" + s.lootToastSlideInPx.ToString("F0") + " · panelPadXPx=" + s.lootToastPanelPadXPx.ToString("F0") +
" · centerOffsetPx=" + s.lootToastCenterOffsetPx.ToString("F0") + "(유지)");
sb.AppendLine();
// ── A. 클램프 OFF = Q4-a 가 본 상태를 그대로 재현
s.lootToastClampToSafeArea = false;
toast.Initialize();
sb.AppendLine("[A] 클램프 OFF(= 813y 현재 · D-7 재현)");
sb.AppendLine(" " + toast.LastClamp);
float overOff = s.lootToastSlideInPx + s.lootToastPanelPadXPx - s.lootToastRightMarginPx;
sb.AppendLine(" 등장 프레임 우측 침범 = slideIn " + s.lootToastSlideInPx.ToString("F0") + " + 패널여백 " +
s.lootToastPanelPadXPx.ToString("F0") + " 우여백 " + s.lootToastRightMarginPx.ToString("F0") +
" = **+" + overOff.ToString("F0") + " px**(Safe Area 밖 · " + s.lootToastSlideSeconds.ToString("F2") +
"s 동안) → Q4-a 캡처 23_arena_12 / 29_levelup_01 의 잘린 `[-]` 와 일치 · " + Ok(overOff > 0f));
sb.AppendLine();
// ── B. 클램프 ON
s.lootToastClampToSafeArea = true;
toast.Initialize();
sb.AppendLine("[B] 클램프 ON");
sb.AppendLine(" " + toast.LastClamp);
var lineRoot = toastRt.Find("Lines") as RectTransform;
var line0 = lineRoot != null ? lineRoot.Find("Line0") as RectTransform : null;
var label0 = line0 != null ? line0.Find("Label") as RectTransform : null;
float rightEdgeUnits = lineRoot != null ? lineRoot.anchoredPosition.x : 0f; // 부모 우측 기준(0 = Safe Area 우변)
float padUnits = (s.lootToastPanelEnabled ? s.lootToastPanelPadXPx : 0f) * u;
float slideMax = (float)typeof(LootToast).GetField("_slideMaxPx", NP).GetValue(toast);
float widthPx = (float)typeof(LootToast).GetField("_widthPx", NP).GetValue(toast);
float overOn = (rightEdgeUnits + slideMax * u + padUnits) / u; // > 0 이면 Safe Area 밖
bool b = overOn <= 0.001f && widthPx <= s.lootToastMaxWidthPx + 0.001f;
sb.AppendLine(" 줄 뿌리 우측 = " + (rightEdgeUnits / u).ToString("F1") + " px(0 = Safe Area 우변) · 슬라이드 상한 = " +
slideMax.ToString("F1") + " px · 패널 여백 " + s.lootToastPanelPadXPx.ToString("F0") + " px");
sb.AppendLine(" 등장 프레임 우측 침범 = **" + overOn.ToString("F1") + " px** (≤ 0 = Safe Area 안) · 실폭 " +
widthPx.ToString("F1") + " px ≤ 상한 " + s.lootToastMaxWidthPx.ToString("F0") + " px · " + Ok(b));
sb.AppendLine();
// ── C. 긴 이름 말줄임
sb.AppendLine("[C] 긴 아이템 이름 말줄임(…)");
var tmp = label0 != null ? label0.GetComponent<TextMeshProUGUI>() : null;
if (tmp == null) sb.AppendLine(" **FAIL** Line0/Label 없음");
else
{
string longName = "아주아주긴전설급아이템이름테스트문자열0123456789";
tmp.text = longName;
tmp.ForceMeshUpdate();
int shownChars = tmp.textInfo.characterCount;
// TMP 는 말줄임을 넣은 뒤 다음 칸에 0x03(End Of Text)을 넣는다(TMP_Text.cs:4067·4077 실측) —
// 그래서 마지막 칸이 아니라 **가시 구간 전체**에서 '…' 를 찾는다.
bool hasEllipsis = false; int ellipsisAt = -1;
for (int ci = 0; ci < shownChars; ci++)
if (tmp.textInfo.characterInfo[ci].character == '…') { hasEllipsis = true; ellipsisAt = ci; break; }
float lineW = tmp.textInfo.lineCount > 0 ? tmp.textInfo.lineInfo[0].width / u : 0f;
float rectW = tmp.rectTransform.rect.width / u;
bool c = tmp.textWrappingMode == TextWrappingModes.NoWrap && tmp.overflowMode == TextOverflowModes.Ellipsis
&& tmp.isTextTruncated && hasEllipsis && lineW <= rectW + 0.5f;
sb.AppendLine(" wrapping=" + tmp.textWrappingMode + " · overflow=" + tmp.overflowMode +
" · 원문 " + longName.Length + "자 → 표시 " + shownChars + "자 · isTextTruncated=" + tmp.isTextTruncated +
" · '…' 위치 " + ellipsisAt + " · 줄 폭 " + lineW.ToString("F1") + " px ≤ rect " +
rectW.ToString("F1") + " px · " + Ok(c));
tmp.text = "";
}
sb.AppendLine();
// ── D. C8 롤백
s.lootToastClampToSafeArea = false;
toast.Initialize();
float w2 = (float)typeof(LootToast).GetField("_widthPx", NP).GetValue(toast);
float sm2 = (float)typeof(LootToast).GetField("_slideMaxPx", NP).GetValue(toast);
bool d = Mathf.Approximately(w2, s.lootToastWidthPx) && Mathf.Approximately(sm2, s.lootToastSlideInPx);
sb.AppendLine("[D] C8 롤백 lootToastClampToSafeArea=0 → 폭 " + w2.ToString("F1") + " px(에셋 " +
s.lootToastWidthPx.ToString("F0") + ") · 슬라이드 " + sm2.ToString("F1") + " px(에셋 " +
s.lootToastSlideInPx.ToString("F0") + ") · " + Ok(d) + " (813y 배치 100%)");
toast.Subscribe(false);
}
catch (Exception ex) { sb.AppendLine("**예외** " + ex); }
finally
{
s.lootToastClampToSafeArea = cl0; s.lootToastMaxWidthPx = mw0;
s.lootToastSafePadPx = sp0; s.lootToastEllipsis = el0;
Kill(root);
}
sb.AppendLine("정리: 합성 노드 파기 · SO 원복 · Killed 구독 해제");
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_toast.txt", body);
}
// ─────────────────────────────────────────────────────────── 전부
public static string All()
{
var sb = new StringBuilder();
sb.AppendLine("################ WL-813z 프로브 전체 ################");
sb.AppendLine(Watchdog()); sb.AppendLine();
sb.AppendLine(Topmost()); sb.AppendLine();
sb.AppendLine(Toast());
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_all.txt", body);
}
}

View File

@ -32,3 +32,10 @@ MonoBehaviour:
reviveInvincibleSeconds: 1.5
reviveHealRate: 1
reviveCompanions: 1
reviveWatchdogSeconds: 20
reviveWatchdogIgnoresDisable: 1
reviveWatchdogLog: 1
reviveTopmostEnabled: 1
reviveSortingOrder: 60
reviveTopmostIntervalSeconds: 0.5
reviveEscalateOnHijack: 1

View File

@ -51,6 +51,9 @@ namespace WL.Combat.Survival
public static int DeathCount, ReviveCount, ExternalReviveCount, AutoReviveCount, AbortCount;
public static float LastDeathToRequest, LastRequestToRevive;
public static string LastAbortReason = "";
/// <summary>WL-813z(D-3) — 부활 워치독이 발동한 횟수 · 마지막 발동까지 걸린 대기 시간(초 · unscaled).</summary>
public static int WatchdogCount;
public static float LastWatchdogSeconds;
/// <summary>프로브 전용 — 부활 실행을 바꿔치기한다(에디트 모드에는 LoadMapMgr·ActorInfo 가 없다). 런타임에서는 null 로 둘 것.</summary>
public static Action<PCActor, float> ReviveOverride;
@ -60,6 +63,8 @@ namespace WL.Combat.Survival
public static bool WorldHeld { get { return s_worldHeld; } }
/// <summary>사망 연출 남은 시간(초 · unscaled · 813j 가 카운트다운에 써도 된다).</summary>
public static float DisplayRemaining { get { var st = St; return s_state == DeathState.Dying && st != null ? Mathf.Max(0f, st.deathDisplaySeconds - (s_now - s_diedAt)) : 0f; } }
/// <summary>부활 팝업이 응답을 기다린 시간(초 · unscaled · WL-813z 워치독 진단).</summary>
public static float AwaitingSeconds { get { return s_state == DeathState.AwaitingRevive ? Mathf.Max(0f, s_now - s_requestedAt) : 0f; } }
static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
@ -83,8 +88,24 @@ namespace WL.Combat.Survival
public static void Tick(float unscaledNow)
{
s_now = unscaledNow;
if (s_state == DeathState.None) return;
if (s_state == DeathState.None) { if (s_worldHeld) ReleaseWorld(); return; } // 상태는 끝났는데 정지만 남은 경우(안전망)
var st = St;
// ── WL-813z(D-3) 부활 워치독 = 마지막 방어선. Enabled 판정보다 **먼저** 본다.
// 공용 Popup 이 에러 모달에 덮여 확인 콜백(m_ok)이 지워져도, SO 스위치가 도중에 꺼져도,
// 이 시간이 지나면 반드시 자동 부활하고 ReleaseWorld() 가 timeScale 을 되돌린다.
if (s_state == DeathState.AwaitingRevive && st != null && st.reviveWatchdogSeconds > 0f
&& (st.reviveWatchdogIgnoresDisable || WLSurvivalSettings.Enabled)
&& s_now - s_requestedAt >= st.reviveWatchdogSeconds)
{
WatchdogCount++;
LastWatchdogSeconds = s_now - s_requestedAt;
if (st.reviveWatchdogLog) // Log(=Error 아님) — ErrorLogHookManager 모달을 부르지 않는다
Debug.Log("[DeathFlow] revive watchdog " + LastWatchdogSeconds.ToString("F1") + "s 초과 — 자동 부활 · 세계 정지 해제(D-3)");
Finish(false, true);
return;
}
if (st == null || !WLSurvivalSettings.Enabled) { Abort("disabled"); return; }
if (s_pc == null) { Abort("pc destroyed"); return; }
if (!s_pc.IsOnlyDead()) { Finish(true, false); return; } // 바깥(원본 After_Die · 리스폰)이 먼저 살렸다
@ -182,6 +203,7 @@ namespace WL.Combat.Survival
if (!s_worldHeld) return;
s_worldHeld = false;
if (Time.timeScale == 0f) Time.timeScale = MyValue.Get_GameSpeed();
if (Time.timeScale <= 0f) Time.timeScale = 1f; // WL-813z(D-3) 안전망 — Get_GameSpeed 가 0 을 주면 그대로 잠긴다(현재 구현은 항상 1f · 실측)
}
/// <summary>프로브용: 상태·카운터 초기화(구독은 유지 · 세계 정지 해제).</summary>
@ -191,6 +213,7 @@ namespace WL.Combat.Survival
s_state = DeathState.None; s_pc = null; s_acceptEarly = false; s_diedAt = s_requestedAt = 0f;
DeathCount = ReviveCount = ExternalReviveCount = AutoReviveCount = AbortCount = 0;
LastDeathToRequest = LastRequestToRevive = 0f; LastAbortReason = "";
WatchdogCount = 0; LastWatchdogSeconds = 0f;
Died.Raised = Died.Dispatched = 0; ReviveRequested.Raised = ReviveRequested.Dispatched = 0; Revived.Raised = Revived.Dispatched = 0;
}
}

View File

@ -128,5 +128,39 @@ namespace WL.Combat.Survival
[Tooltip("부활 시 소환수·펫·일꾼도 On_Regen 한다(원본 After_Die 의 PC 분기와 동일).")]
public bool reviveCompanions = true;
// ─────────────────────────────────────────── 사망 중 영구 정지 방지 (Q4-a D-3 · WL-813z)
// 실측 근거(2026-09-09 · Popup.cs 전문 54줄 · ErrorLogHookManager.cs:33)
// 공용 Popup 은 노드 1개짜리 싱글턴이고 Set() 이 m_ok 를 **덮어쓴다**.
// ErrorLogHookManager 가 Error/Exception 마다 Popup.Ins.Set(One, 174, null, null, log) 을 부르면
// 부활 팝업이 넘긴 확인 콜백(m_ok)이 null 로 지워진다 → 확인을 눌러도 부활이 일어나지 않고
// holdWorldWhileReviving 이 세운 timeScale=0 이 영영 안 풀린다(Q4-a 실측 2분 30초).
// → 아래 워치독이 **마지막 방어선**이다. autoReviveTimeoutSeconds 는 정상 흐름의 응답 상한이고,
// 이 값은 "무슨 일이 있어도 세계는 다시 흐른다"를 보장하는 안전장치라 별도 필드로 둔다.
[Header("사망 중 영구 정지 방지 (Q4-a D-3 · WL-813z)")]
[Tooltip("AwaitingRevive(부활 팝업 대기)가 이 시간(초 · unscaled)을 넘기면 자동 부활하고 세계 정지를 푼다. 0 = 워치독 없음(C8 롤백 = 0).")]
public float reviveWatchdogSeconds = 20f;
[Tooltip("켜면 이 SO 의 enabled·RuntimeOverride 가 꺼져도 워치독만은 계속 돈다(이미 세계를 세워 둔 상태에서 스위치가 꺼져 잠기는 것을 막는다).")]
public bool reviveWatchdogIgnoresDisable = true;
[Tooltip("워치독이 발동하면 Console 에 Log 1줄을 남긴다(Warning/Error 가 아니라 ErrorLogHookManager 모달을 부르지 않는다).")]
public bool reviveWatchdogLog = true;
// ─────────────────────────────────────────── 부활 팝업 최상단 보장 (UI · ReviveDialog 가 읽는다)
// 🔴 UI 값이지만 이번 발주(WL-813z §2)가 허용한 UI 파일 목록에 WLSurvivalUiSettings.cs 가 없어
// 813q 와의 충돌 0 을 지키려고 사망/부활 소유 SO 인 여기에 둔다(Lead 판단으로 이관 가능).
[Header("부활 팝업 최상단 보장 (D-3 · ReviveDialog 가 읽는다 · WL-813z)")]
[Tooltip("끄면 최상단 보장·재보장·승격을 전부 하지 않는다(C8 롤백 = 0 · 813y 동작 100%).")]
public bool reviveTopmostEnabled = true;
[Tooltip("부활 팝업 전용 캔버스의 sortingOrder. 공용 팝업 프리팹 SortOrder_5 의 Canvas.m_SortingOrder = 5 (실측) 보다 커야 한다.")]
public int reviveSortingOrder = 60;
[Tooltip("표시 중 최상단·레이캐스트를 다시 보장하는 주기(초 · unscaled · 0 이면 표시 순간 1회만). GC 0 경로.")]
public float reviveTopmostIntervalSeconds = 0.5f;
[Tooltip("공용 Popup 이 다른 팝업(에러 모달 등)에 덮인 것을 감지하면 전용 폴백 팝업으로 승격해 확인 버튼을 되살린다.")]
public bool reviveEscalateOnHijack = true;
}
}

View File

@ -54,6 +54,11 @@ namespace WL.UI
private float _nextPoll;
private bool _armed; // Killed 를 한 번이라도 본 뒤부터 폴링한다(로비에서 헛돌지 않게)
// ── WL-813z(D-7) Safe Area 클램프 실값(ApplyLayout 이 계산 · Update 슬라이드가 읽는다)
private float _widthPx, _rightPx, _padPx, _slideMaxPx;
/// <summary>WL-813z — 마지막 클램프 실측(프로브·Dump 용).</summary>
public string LastClamp { get; private set; } = "";
// ── 진단(프로브가 읽는다 · 실측만)
public int KilledSeen { get; private set; }
public int PushCount { get; private set; }
@ -132,25 +137,55 @@ namespace WL.UI
float u = WLTextFxUtil.UnitsPerPx(this, s);
WLTextFxUtil.Stretch(_rt);
// ── WL-813z(D-7) — 폭·오른쪽 여백·슬라이드 시작 x 를 Safe Area 안으로 클램프.
// 부모(IngameUIs/WL_HUD)는 SafeAreaFitter 아래이므로 이 RectTransform 의 rect 가 곧 Safe Area 다.
// 중심 오프셋(lootToastCenterOffsetPx)은 발주서 지시대로 **그대로 둔다**.
_widthPx = s.lootToastWidthPx;
_rightPx = s.lootToastRightMarginPx;
_padPx = 0f;
_slideMaxPx = s.lootToastSlideInPx;
float parentPx = u > 0.0001f ? _rt.rect.width / u : 0f;
if (s.lootToastClampToSafeArea)
{
_padPx = Mathf.Max(0f, s.lootToastSafePadPx) + (s.lootToastPanelEnabled ? Mathf.Max(0f, s.lootToastPanelPadXPx) : 0f);
_rightPx = Mathf.Max(_padPx, _rightPx);
if (s.lootToastMaxWidthPx > 0f) _widthPx = Mathf.Min(_widthPx, s.lootToastMaxWidthPx);
if (parentPx > 1f) _widthPx = Mathf.Min(_widthPx, parentPx - _rightPx - _padPx);
_widthPx = Mathf.Max(1f, _widthPx);
_slideMaxPx = Mathf.Clamp(_rightPx - _padPx, 0f, s.lootToastSlideInPx); // 등장 중에도 오른쪽 밖으로 못 나간다
}
LastClamp = "부모=" + parentPx.ToString("F1") + "px · 폭 " + s.lootToastWidthPx.ToString("F0") + "→" + _widthPx.ToString("F1") +
"px(상한 " + s.lootToastMaxWidthPx.ToString("F0") + ") · 우여백 " + s.lootToastRightMarginPx.ToString("F0") + "→" + _rightPx.ToString("F1") +
"px · pad=" + _padPx.ToString("F1") + "px · 슬라이드 " + s.lootToastSlideInPx.ToString("F0") + "→" + _slideMaxPx.ToString("F1") +
"px · 우측 최대 침범 " + Mathf.Max(0f, _slideMaxPx + _padPx - _rightPx).ToString("F1") + "px · clamp=" + s.lootToastClampToSafeArea;
lineRoot.anchorMin = lineRoot.anchorMax = new Vector2(1f, 0.5f); // 우측 · 세로 중앙 기준
lineRoot.pivot = new Vector2(1f, 0.5f);
lineRoot.sizeDelta = new Vector2(s.lootToastWidthPx * u, s.lootToastLineHeightPx * s.lootToastLines * u);
lineRoot.anchoredPosition = new Vector2(-s.lootToastRightMarginPx * u, s.lootToastCenterOffsetPx * u);
lineRoot.sizeDelta = new Vector2(_widthPx * u, s.lootToastLineHeightPx * s.lootToastLines * u);
lineRoot.anchoredPosition = new Vector2(-_rightPx * u, s.lootToastCenterOffsetPx * u);
var sb = new StringBuilder();
sb.Append("lines=").Append(_lines.Count).Append(" root pos=").Append(lineRoot.anchoredPosition)
.Append(" size=").Append(lineRoot.sizeDelta).Append(" unitsPerPx=").Append(u.ToString("F4"));
.Append(" size=").Append(lineRoot.sizeDelta).Append(" unitsPerPx=").Append(u.ToString("F4"))
.Append(" · ").Append(LastClamp);
for (int i = 0; i < _lines.Count; i++)
{
var l = _lines[i];
l.rt.anchorMin = l.rt.anchorMax = new Vector2(1f, 1f);
l.rt.pivot = new Vector2(1f, 1f);
l.rt.sizeDelta = new Vector2(s.lootToastWidthPx * u, s.lootToastLineHeightPx * u);
l.rt.sizeDelta = new Vector2(_widthPx * u, s.lootToastLineHeightPx * u);
l.rt.anchoredPosition = new Vector2(0f, -s.lootToastLineHeightPx * i * u);
WLTextFxUtil.Stretch(l.label.rectTransform);
l.label.fontSize = s.lootToastFontPx * u;
l.label.alignment = TextAlignmentOptions.MidlineRight;
// WL-813z(D-7) — 긴 아이템 이름은 줄바꿈 대신 말줄임(…)으로 자른다(폭 밖으로 밀려나지 않게).
if (s.lootToastEllipsis)
{
if (l.label.textWrappingMode != TextWrappingModes.NoWrap) l.label.textWrappingMode = TextWrappingModes.NoWrap;
if (l.label.overflowMode != TextOverflowModes.Ellipsis) l.label.overflowMode = TextOverflowModes.Ellipsis;
}
// WL-813y — 줄 뒤 패널(대비) + 글자 아웃라인. 값은 전부 SO.
if (l.panel != null)
{
@ -293,7 +328,9 @@ namespace WL.UI
if (s.lootToastSlideInPx > 0f && s.lootToastSlideSeconds > 0f)
{
float k = Mathf.Clamp01(l.elapsed / s.lootToastSlideSeconds);
float x = Mathf.Lerp(s.lootToastSlideInPx, 0f, k) * u;
// WL-813z(D-7) — 등장 x 상한 = Safe Area 를 넘지 않는 값(ApplyLayout 계산 · 클램프 off 면 원래 값).
float startPx = s.lootToastClampToSafeArea ? _slideMaxPx : s.lootToastSlideInPx;
float x = Mathf.Lerp(startPx, 0f, k) * u;
l.rt.anchoredPosition = new Vector2(x, -s.lootToastLineHeightPx * i * u);
}
if (l.life <= 0f) { l.used = false; l.group.alpha = 0f; l.label.text = ""; }
@ -369,6 +406,7 @@ namespace WL.UI
" rightMarginPx=" + st.lootToastRightMarginPx + " centerOffsetPx=" + st.lootToastCenterOffsetPx +
" panel=" + st.lootToastPanelEnabled + " 밝기하한=" + st.lootToastMinBrightness +
" · 등급1 태그 " + st.GradeColorTag(1) + " → " + st.GradeColorTagBright(1));
sb.AppendLine(" clamp = " + LastClamp);
sb.AppendLine(" edge = " + LastEdge);
return sb.ToString();
}

View File

@ -24,10 +24,22 @@
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
// ■ WL-813z (Q4-a D-3 · D-9) — 2026-09-09
// D-3 실측: 공용 `Popup` 은 **노드 1개짜리 싱글턴**(`Assets/Script/Info/Popup.cs` 전문 54줄)이고
// `Set()` 이 `m_ok` 를 매번 덮어쓴다. `Util/ErrorLogHookManager.cs:33` 이 Error/Exception 마다
// `Popup.Ins.Set(One, 174, null, null, log)` 을 부르므로 **부활 팝업이 넘긴 확인 콜백이 null 로 지워진다**
// → 확인을 눌러도 부활이 안 되고 `timeScale=0` 이 영영 안 풀린다(Q4-a 슬롯① 2분 30초).
// 대응 3층: ① 813i DeathFlow.reviveWatchdogSeconds(자동 부활 · 마지막 방어선)
// ② 전용 노드를 최상단 캔버스(sortingOrder > SortOrder_5 의 5)로 올려 확인 버튼을 항상 노출
// ③ 공용 Popup 이 남의 내용으로 바뀌면 감지해 전용 팝업으로 **승격**
// 부수 수정: `HideNow()` 가 **우리 것일 때만** 공용 Popup 도 닫는다(Q4-a QA-4 「팝업이 안 닫힌다」).
// D-9: 런타임 생성 확인 버튼 이름 `btn_ok` → `WL_btn_ok`(원본 `NightmareResult/btn_ok` 와 이름 충돌 제거).
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Survival;
namespace WL.UI
{
@ -93,6 +105,14 @@ namespace WL.UI
private float _showAt = -1f;
private bool _open; // 팝업이 열려 있는가(공용 Popup 경로 포함 · WL-813y)
// ── WL-813z(D-3) 최상단 보장 ──────────────────────────────────────────
/// <summary>런타임 생성 확인 버튼 이름 — 원본 `NightmareResult/btn_ok` 와 겹치지 않게 접두어를 붙인다(D-9).</summary>
public const string OkButtonName = "WL_btn_ok";
private Canvas _ownCanvas;
private GraphicRaycaster _ownRaycaster;
private float _nextTopmostAt = -1f;
private bool _escalated; // 공용 Popup 이 덮여 전용 팝업으로 승격했다
// ── 진단 ──────────────────────────────────────────────────────────────
public int DeathSeen { get; private set; }
public int ShownCount { get; private set; }
@ -101,6 +121,14 @@ namespace WL.UI
public string LastMessage { get; private set; } = "";
public bool FallbackVisible { get { return group != null && group.alpha > 0.001f; } }
public bool Pending { get { return _showAt >= 0f; } }
/// <summary>WL-813z — 최상단 재보장 횟수 · 공용 Popup 탈취 감지 횟수 · 우리가 닫은 공용 Popup 수.</summary>
public int TopmostCount { get; private set; }
public int HijackCount { get; private set; }
public int CommonPopupClosed { get; private set; }
public bool Escalated { get { return _escalated; } }
public bool IsOpen { get { return _open; } }
public int SortingOrder { get { return _ownCanvas != null && _ownCanvas.overrideSorting ? _ownCanvas.sortingOrder : -1; } }
public bool IsLastSibling { get { return _rt != null && _rt.parent != null && _rt.GetSiblingIndex() == _rt.parent.childCount - 1; } }
private void Awake() { _rt = GetComponent<RectTransform>(); Active = this; }
private void OnEnable() { Active = this; Initialize(); }
@ -130,14 +158,85 @@ namespace WL.UI
private void Update() { Tick(Time.unscaledTime); }
/// <summary>지연 만료 검사. 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
/// <summary>지연 만료 검사 + 표시 중 최상단 재보장. 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
public void Tick(float now)
{
// ── WL-813z(D-3) 표시 중 재보장(GC 0 — 문자열·할당 없음 · 바뀐 값만 쓴다)
if (_open)
{
var g = WLSurvivalSettings.Instance;
if (g != null && g.reviveTopmostEnabled && g.reviveTopmostIntervalSeconds > 0f && now >= _nextTopmostAt)
{
_nextTopmostAt = now + g.reviveTopmostIntervalSeconds;
EnsureTopmost(g);
CheckCommonPopupHijack(g);
}
}
if (_showAt < 0f || now < _showAt) return;
_showAt = -1f;
ShowNow();
}
/// <summary>
/// WL-813z(D-3) — 전용 노드를 형제 맨 뒤 + 전용 캔버스(sortingOrder = SO)로 올리고 레이캐스트를 살린다.
/// 공용 팝업 프리팹 `SortOrder_5` 의 Canvas.m_SortingOrder = 5(실측)보다 커야 확인 버튼이 안 가린다.
/// 값이 이미 맞으면 아무 것도 쓰지 않는다(GC 0 · SetDirty 0).
/// </summary>
public void EnsureTopmost(WLSurvivalSettings g)
{
if (g == null || !g.reviveTopmostEnabled) return;
if (_rt == null) _rt = GetComponent<RectTransform>();
if (_rt == null) return;
var parent = _rt.parent;
if (parent != null && _rt.GetSiblingIndex() != parent.childCount - 1) _rt.SetAsLastSibling();
if (_ownCanvas == null)
{
_ownCanvas = GetComponent<Canvas>();
if (_ownCanvas == null) _ownCanvas = gameObject.AddComponent<Canvas>();
}
if (!_ownCanvas.overrideSorting) _ownCanvas.overrideSorting = true;
if (_ownCanvas.sortingOrder != g.reviveSortingOrder) _ownCanvas.sortingOrder = g.reviveSortingOrder;
// 전용 캔버스를 얹으면 부모 GraphicRaycaster 의 레이캐스트가 여기서 끊긴다 — 자기 것을 붙인다.
if (_ownRaycaster == null)
{
_ownRaycaster = GetComponent<GraphicRaycaster>();
if (_ownRaycaster == null) _ownRaycaster = gameObject.AddComponent<GraphicRaycaster>();
}
if (!_ownRaycaster.enabled) _ownRaycaster.enabled = true;
TopmostCount++;
}
/// <summary>
/// WL-813z(D-3) — 공용 Popup 이 남의 내용으로 바뀌었는지(= 우리 확인 콜백 m_ok 가 지워졌는지) 본다.
/// m_ok 는 private 이라 직접 못 읽으므로 **본문 텍스트와 활성 상태**로 판정한다(문자열 비교 = 할당 0).
/// 탈취를 확인하면 전용 폴백 팝업으로 승격한다 — 그쪽은 최상단 캔버스라 확인 버튼에 반드시 닿는다.
/// </summary>
private void CheckCommonPopupHijack(WLSurvivalSettings g)
{
if (!g.reviveEscalateOnHijack || !UsedCommonPopup || _escalated) return;
bool lost;
try
{
var p = Popup.Ins;
if (p == null || p.m_popup == null || p.label_msg == null) lost = true;
else lost = !p.m_popup.activeSelf || !string.Equals(p.label_msg.text, LastMessage);
}
catch { lost = true; }
if (!lost) return;
HijackCount++;
_escalated = true;
UsedCommonPopup = false;
var s = WLSurvivalUiSettings.Instance;
if (s != null) ShowFallback(s, LastMessage);
Debug.Log("[ReviveDialog] 공용 Popup 이 다른 팝업에 덮였다 — 전용 부활 팝업으로 승격(D-3)");
}
/// <summary>예약을 지우고, 아직 안 떠 있을 때만 연다(WL-813y · 813i 이중 통지 방지).</summary>
public string ShowNowOnce()
{
@ -157,6 +256,12 @@ namespace WL.UI
LastMessage = msg;
ShownCount++;
UsedCommonPopup = false;
_escalated = false;
// WL-813z(D-3) — 표시 순간 최상단 1회 + 재보장 시각 예약(공용 팝업 경로에서도 승격 대비로 올려 둔다)
var g = WLSurvivalSettings.Instance;
EnsureTopmost(g);
_nextTopmostAt = Time.unscaledTime + (g != null ? Mathf.Max(0f, g.reviveTopmostIntervalSeconds) : 0f);
if (s.reviveUseCommonPopup)
{
@ -211,6 +316,7 @@ namespace WL.UI
ApplyLayout();
if (messageLabel != null) messageLabel.text = msg;
if (group != null) { group.alpha = 1f; group.blocksRaycasts = true; group.interactable = true; }
EnsureTopmost(WLSurvivalSettings.Instance); // WL-813z(D-3) — 승격 직후에도 최상단·레이캐스트 보장
}
/// <summary>확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다.</summary>
@ -226,11 +332,33 @@ namespace WL.UI
public void HideNow()
{
_showAt = -1f;
bool wasOpen = _open;
_open = false;
_nextTopmostAt = -1f;
_escalated = false;
if (wasOpen) CloseCommonPopupIfOurs(); // WL-813z — QA-4 「부활했는데 공용 팝업이 남는다」
if (group == null) return;
group.alpha = 0f; group.blocksRaycasts = false; group.interactable = false;
}
/// <summary>
/// WL-813z — 공용 Popup 이 **아직 우리 본문을 들고 있을 때만** 닫는다(남의 팝업은 건드리지 않는다).
/// `OnClick_Back()` = 원본 `ClosePopup()`(SetActive(false) + BackKeyMgr.Del_Back) + `m_no?.Invoke()`.
/// 우리는 `_no` 를 넘긴 적이 없고 본문이 우리 것이면 `Set()` 이 다시 안 불린 것이라 `m_no` 는 null 이다(Popup.cs:14 실측).
/// </summary>
private void CloseCommonPopupIfOurs()
{
try
{
var p = Popup.Ins;
if (p == null || p.m_popup == null || !p.m_popup.activeSelf) return;
if (p.label_msg == null || !string.Equals(p.label_msg.text, LastMessage)) return;
p.OnClick_Back();
CommonPopupClosed++;
}
catch { /* 씬 미로드·에디트 모드 — 무동작 */ }
}
// ── 구성 · 배치 (폴백 팝업) ──────────────────────────────────────────
public bool BuildIfNeeded()
{
@ -266,7 +394,9 @@ namespace WL.UI
}
if (okButton == null)
{
var t = WLVignetteUtil.NewChild(box, "btn_ok");
// WL-813z(D-9): 이름이 원본 `NewGameUI(Clone)/…/NightmareResult/btn_ok` 와 겹쳐
// `LightProbe.ClickButton ["btn_ok"]` 가 비활성인 원본 쪽을 잡았다 → `WL_` 접두어로 분리.
var t = WLVignetteUtil.NewChild(box, OkButtonName);
var img = t.GetComponent<Image>();
if (img == null) img = t.gameObject.AddComponent<Image>();
img.raycastTarget = true;
@ -287,6 +417,8 @@ namespace WL.UI
if (font != null) okLabel.font = font;
made = true;
}
// WL-813z(D-9) — 이전 세션이 만든(또는 직렬화된) `btn_ok` 도 이름을 통일한다.
if (okButton != null && okButton.gameObject.name != OkButtonName) okButton.gameObject.name = OkButtonName;
return made;
}
@ -357,6 +489,30 @@ namespace WL.UI
}
// ── 검증 전용 ────────────────────────────────────────────────────────
/// <summary>
/// 프로브 전용(WL-813z) — 에디트 모드에는 공용 `Popup` 싱글턴이 없어 그 경로를 탈 수 없다.
/// "공용 Popup 으로 열려 있다" 상태만 만들어 탈취 감지·승격 경로를 같은 코드로 검증한다. 런타임 호출 0.
/// </summary>
public string ProbeSimulateCommonPopup(string msg)
{
LastMessage = msg ?? "";
UsedCommonPopup = true;
_escalated = false;
_open = true;
ShownCount++;
return "공용 Popup 사용 표시 · open=" + _open + " 본문=\"" + LastMessage.Replace("\n", " / ") + "\"";
}
/// <summary>프로브 전용(WL-813z) — 탈취 감지 1회를 즉시 돌린다.</summary>
public string ProbeCheckHijack()
{
var g = WLSurvivalSettings.Instance;
if (g == null) return "WLSurvivalSettings 에셋 없음 — 판정 불가";
CheckCommonPopupHijack(g);
return "탈취감지=" + HijackCount + "회 · 승격=" + _escalated + " · 공용팝업사용=" + UsedCommonPopup +
" · 폴백표시=" + FallbackVisible + " · 맨뒤형제=" + IsLastSibling + " · sortingOrder=" + SortingOrder;
}
public string Dump()
{
var sb = new StringBuilder();
@ -368,9 +524,21 @@ namespace WL.UI
" 브리지=" + WLSurvivalUiBridge.Installed);
sb.AppendLine(" 본문=\"" + LastMessage.Replace("\n", " / ") + "\"");
sb.AppendLine(" 공용 Popup.Ins=" + (Popup.Ins != null ? "있음" : "없음(에디트 모드/씬 미로드)"));
// WL-813z(D-3/D-9)
var g = WLSurvivalSettings.Instance;
sb.AppendLine(" 최상단 = sortingOrder " + SortingOrder + "(SO " + (g != null ? g.reviveSortingOrder.ToString() : "-") +
") · 맨뒤형제=" + IsLastSibling + " · 재보장=" + TopmostCount + "회 · 탈취감지=" + HijackCount +
" · 승격=" + _escalated + " · 공용팝업닫음=" + CommonPopupClosed +
" · raycaster=" + (_ownRaycaster != null && _ownRaycaster.enabled) +
" · blocksRaycasts=" + (group != null && group.blocksRaycasts));
sb.AppendLine(" 워치독 = " + (g == null ? "(WLSurvivalSettings 없음)" :
g.reviveWatchdogSeconds.ToString("F1") + "s · 무시disable=" + g.reviveWatchdogIgnoresDisable +
" · 최상단ON=" + g.reviveTopmostEnabled + " 주기=" + g.reviveTopmostIntervalSeconds.ToString("F2") + "s") +
" · DeathFlow 발동=" + DeathFlow.WatchdogCount + "회 대기=" + DeathFlow.AwaitingSeconds.ToString("F2") + "s");
if (box != null)
sb.AppendLine(" 폴백 box size" + box.sizeDelta + " pos" + box.anchoredPosition +
" ok=\"" + (okLabel != null ? okLabel.text : "-") + "\"");
" ok=\"" + (okLabel != null ? okLabel.text : "-") + "\" okName=" +
(okButton != null ? okButton.gameObject.name : "-"));
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"enabled=" + s.reviveEnabled + " 공용팝업=" + s.reviveUseCommonPopup +
" delay=" + s.reviveDelaySeconds + "s key=" + s.reviveMessageKey +

View File

@ -127,3 +127,7 @@ MonoBehaviour:
lootToastPanelPadYPx: 6
lootToastMinBrightColor: {r: 0.95, g: 0.96, b: 0.98, a: 1}
lootToastMinBrightness: 0.72
lootToastClampToSafeArea: 1
lootToastMaxWidthPx: 560
lootToastSafePadPx: 8
lootToastEllipsis: 1

View File

@ -219,6 +219,24 @@ namespace WL.UI
[Tooltip("등급 색 팔레트(index 0 = 등급 1). 비우면 MyValue.Get_GradeColor(코드 팔레트)를 그대로 쓴다.")]
public Color[] lootToastGradeColors = new Color[0];
// ─────────────────────────────────────── 전리품 토스트 Safe Area 클램프 (Q4-a D-7 · WL-813z)
// 실측 원인: 줄이 등장할 때 x 가 +lootToastSlideInPx(60) 에서 0 으로 온다. 줄 뿌리는 오른쪽 가장자리에서
// lootToastRightMarginPx(40) 안쪽에 있으므로 등장 프레임의 오른쪽 끝은 40 + 60 = **+20 px**,
// 뒤 패널(lootToastPanelPadXPx = 18)까지 더하면 **+38 px** 가 Safe Area 밖으로 나간다
// (0.14 s 동안 · Q4-a 캡처 `23_arena_12.png`·`29_levelup_01.png` 의 잘린 `[-]` 조각과 일치).
[Header("전리품 토스트 Safe Area 클램프 (Q4-a D-7 · WL-813z)")]
[Tooltip("끄면 813y 배치 100%(C8 롤백 = 0). 켜면 폭·오른쪽 여백·슬라이드 시작 x 를 Safe Area 안으로 자른다.")]
public bool lootToastClampToSafeArea = true;
[Tooltip("줄 폭 상한(px · 0 이면 상한 없음). 실제 폭 = min(lootToastWidthPx, 이 값, 부모 폭 좌우 여백).")]
public float lootToastMaxWidthPx = 560f;
[Tooltip("Safe Area 안쪽으로 더 확보할 여백(px). 뒤 패널 여백(lootToastPanelPadXPx)은 여기에 자동으로 더해진다.")]
public float lootToastSafePadPx = 8f;
[Tooltip("이름이 폭을 넘으면 줄바꿈 대신 말줄임(…)으로 자른다(TMP textWrappingMode=NoWrap · overflowMode=Ellipsis).")]
public bool lootToastEllipsis = true;
// ── ④ 레벨업 스탯 팝업 (기준서 §B 요소 5) ─────────────────────────────
[Header("④ 레벨업 스탯 팝업 (CombatEvents.LevelUp 구독 · 스탯 3줄)")]
[Tooltip("스탯 팝업 표시 여부.")]