// ───────────────────────────────────────────────────────────────────────────── // 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 단일). // ───────────────────────────────────────────────────────────────────────────── // ■ 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 { [DisallowMultipleComponent] [RequireComponent(typeof(RectTransform))] public class ReviveDialog : MonoBehaviour { // ── 813i 연결점 (「후속」) ──────────────────────────────────────────── /// 확인을 누르면 불린다. 813i 가 존 시작점 부활을 붙인다. public static System.Action ReviveRequested; /// 팝업이 뜰 때 불린다(진단·튜토리얼용 · 선택). public static System.Action ReviveShown; /// 현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다). public static ReviveDialog Active { get; private set; } /// 813i DeathFlow 가 사망 연출 뒤 부르는 진입점. 인스턴스가 없으면 무동작. public static string NotifyDeath() { if (Active == null) return "ReviveDialog 인스턴스 없음 — 무동작"; return Active.QueueShow(Time.unscaledTime); } // ── WL-813y — 813i DeathFlow 와 이어 붙인 진입점 2개 ────────────────── /// /// `DeathFlow.ReviveRequested` 진입점 — 813i 가 **사망 연출을 이미 기다렸으므로** 지연 없이 연다. /// (`NotifyDeath()` 의 UI 지연과 겹쳐 두 번 뜨지 않도록 예약을 지우고 1회만 연다.) /// public static string NotifyReviveRequested() { if (Active == null) return "ReviveDialog 인스턴스 없음 — 무동작"; return Active.ShowNowOnce(); } /// `DeathFlow.Revived` 진입점 — 부활이 끝났으니 팝업을 닫는다(자동/외부 부활 포함). public static string NotifyRevived() { if (Active == null) return "ReviveDialog 인스턴스 없음 — 무동작"; Active.HideNow(); return "부활 완료 — 팝업 닫음"; } [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; private bool _open; // 팝업이 열려 있는가(공용 Popup 경로 포함 · WL-813y) // ── WL-813z(D-3) 최상단 보장 ────────────────────────────────────────── /// 런타임 생성 확인 버튼 이름 — 원본 `NightmareResult/btn_ok` 와 겹치지 않게 접두어를 붙인다(D-9). 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; } 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; } } /// WL-813z — 최상단 재보장 횟수 · 공용 Popup 탈취 감지 횟수 · 우리가 닫은 공용 Popup 수. 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(); Active = this; } private void OnEnable() { Active = this; Initialize(); } private void OnDisable() { if (Active == this) Active = null; } public string Initialize() { if (_rt == null) _rt = GetComponent(); Active = this; BuildIfNeeded(); string layout = ApplyLayout(); HideNow(); return "initialized · " + layout; } /// 사망 통지 → 설정된 지연 뒤에 팝업을 연다(사망 연출 시간은 813i 소유). 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); } /// 지연 만료 검사 + 표시 중 최상단 재보장. 프로브가 시간을 넣어 같은 경로로 검증한다. 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(); } /// /// WL-813z(D-3) — 전용 노드를 형제 맨 뒤 + 전용 캔버스(sortingOrder = SO)로 올리고 레이캐스트를 살린다. /// 공용 팝업 프리팹 `SortOrder_5` 의 Canvas.m_SortingOrder = 5(실측)보다 커야 확인 버튼이 안 가린다. /// 값이 이미 맞으면 아무 것도 쓰지 않는다(GC 0 · SetDirty 0). /// public void EnsureTopmost(WLSurvivalSettings g) { if (g == null || !g.reviveTopmostEnabled) return; if (_rt == null) _rt = GetComponent(); if (_rt == null) return; var parent = _rt.parent; if (parent != null && _rt.GetSiblingIndex() != parent.childCount - 1) _rt.SetAsLastSibling(); if (_ownCanvas == null) { _ownCanvas = GetComponent(); if (_ownCanvas == null) _ownCanvas = gameObject.AddComponent(); } if (!_ownCanvas.overrideSorting) _ownCanvas.overrideSorting = true; if (_ownCanvas.sortingOrder != g.reviveSortingOrder) _ownCanvas.sortingOrder = g.reviveSortingOrder; // 전용 캔버스를 얹으면 부모 GraphicRaycaster 의 레이캐스트가 여기서 끊긴다 — 자기 것을 붙인다. if (_ownRaycaster == null) { _ownRaycaster = GetComponent(); if (_ownRaycaster == null) _ownRaycaster = gameObject.AddComponent(); } if (!_ownRaycaster.enabled) _ownRaycaster.enabled = true; TopmostCount++; } /// /// WL-813z(D-3) — 공용 Popup 이 남의 내용으로 바뀌었는지(= 우리 확인 콜백 m_ok 가 지워졌는지) 본다. /// m_ok 는 private 이라 직접 못 읽으므로 **본문 텍스트와 활성 상태**로 판정한다(문자열 비교 = 할당 0). /// 탈취를 확인하면 전용 폴백 팝업으로 승격한다 — 그쪽은 최상단 캔버스라 확인 버튼에 반드시 닿는다. /// 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)"); } /// 예약을 지우고, 아직 안 떠 있을 때만 연다(WL-813y · 813i 이중 통지 방지). public string ShowNowOnce() { _showAt = -1f; if (_open) return "이미 표시 중 — 중복 무시(shows=" + ShownCount + ")"; return ShowNow(); } /// 지금 바로 팝업을 연다(공용 Popup 우선 · 실패하면 폴백 노드). public string ShowNow() { var s = WLSurvivalUiSettings.Instance; if (s == null) return "WLSurvivalUiSettings 에셋 없음"; _open = true; string msg = BuildMessage(s); 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) { 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 + "\""; } /// 본문 = 설정 문자열(로컬라이즈되면 reviveMessageKey 우선) + 비용 줄. 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; } /// 공용 Popup 싱글턴 재사용. 실패(미로드·표 없음)하면 UsedCommonPopup=false 로 남긴다. 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; } EnsureTopmost(WLSurvivalSettings.Instance); // WL-813z(D-3) — 승격 직후에도 최상단·레이캐스트 보장 } /// 확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다. public string OnConfirm() { ConfirmCount++; HideNow(); if (ReviveRequested == null) return "확인 — 813i(ReviveRequested) 미연결이라 부활 동작 없음(후속)"; try { ReviveRequested(); } catch { } return "확인 — 부활 요청 전달"; } 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; } /// /// WL-813z — 공용 Popup 이 **아직 우리 본문을 들고 있을 때만** 닫는다(남의 팝업은 건드리지 않는다). /// `OnClick_Back()` = 원본 `ClosePopup()`(SetActive(false) + BackKeyMgr.Del_Back) + `m_no?.Invoke()`. /// 우리는 `_no` 를 넘긴 적이 없고 본문이 우리 것이면 `Set()` 이 다시 안 불린 것이라 `m_no` 는 null 이다(Popup.cs:14 실측). /// 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() { if (_rt == null) _rt = GetComponent(); bool made = false; if (group == null) { group = GetComponent(); if (group == null) { group = gameObject.AddComponent(); made = true; } } if (dim == null) { var d = WLVignetteUtil.NewChild(_rt, "Dim"); WLVignetteUtil.Stretch(d); dim = d.GetComponent(); if (dim == null) dim = d.gameObject.AddComponent(); dim.raycastTarget = true; made = true; } if (box == null) { box = WLVignetteUtil.NewChild(_rt, "Box"); made = true; } if (boxBg == null) { boxBg = box.GetComponent(); if (boxBg == null) { boxBg = box.gameObject.AddComponent(); made = true; } boxBg.raycastTarget = true; } if (messageLabel == null) { var t = WLVignetteUtil.NewChild(box, "Msg"); messageLabel = t.GetComponent(); if (messageLabel == null) messageLabel = t.gameObject.AddComponent(); messageLabel.raycastTarget = false; messageLabel.alignment = TextAlignmentOptions.Center; if (font != null) messageLabel.font = font; made = true; } if (okButton == null) { // WL-813z(D-9): 이름이 원본 `NewGameUI(Clone)/…/NightmareResult/btn_ok` 와 겹쳐 // `LightProbe.ClickButton ["btn_ok"]` 가 비활성인 원본 쪽을 잡았다 → `WL_` 접두어로 분리. var t = WLVignetteUtil.NewChild(box, OkButtonName); var img = t.GetComponent(); if (img == null) img = t.gameObject.AddComponent(); img.raycastTarget = true; okButton = t.GetComponent