diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset
index 924902897..f4a6f7362 100644
--- a/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset
+++ b/Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset
@@ -17,7 +17,7 @@ MonoBehaviour:
pollSeconds: 0.1
tickIntervalSeconds: 1
pauseTimerWhileWorldHeld: 1
- autoEnterOnMapEnter: 0
+ autoEnterOnMapEnter: 1
warpPlayerToAnchor: 1
useBarrierHook: 1
clearBarrierOnExit: 1
@@ -32,7 +32,7 @@ MonoBehaviour:
retryRecallsMonsters: 1
retryResetsPotions: 1
retryHealsPlayer: 1
- bindReviveOkToRetry: 1
+ bindReviveOkToRetry: 0
clearHoldScale: 0
mirrorRunEnded: 1
mirrorRunHudEvents: 1
@@ -41,4 +41,4 @@ MonoBehaviour:
suppressRunDirector: 1
disableRunGuide: 1
resetRunDirectorOnEnter: 1
- toLobbyCallsReturnToLobby: 0
+ toLobbyCallsReturnToLobby: 1
diff --git a/Assets/WL/Combat/Stage/Barrier/Resources/WL/WLStageBarrierSettings.asset b/Assets/WL/Combat/Stage/Barrier/Resources/WL/WLStageBarrierSettings.asset
index 0a7ec290a..1c2efafe3 100644
--- a/Assets/WL/Combat/Stage/Barrier/Resources/WL/WLStageBarrierSettings.asset
+++ b/Assets/WL/Combat/Stage/Barrier/Resources/WL/WLStageBarrierSettings.asset
@@ -37,7 +37,7 @@ MonoBehaviour:
carveHeight: 3
carveOverlap: 0.15
carveStaticOnly: 1
- style: 1
+ style: 2
showVisual: 1
appearSeconds: 0.6
fadeSeconds: 0.6
diff --git a/Assets/WL/Combat/Stage/StageBarrierBinding.cs b/Assets/WL/Combat/Stage/StageBarrierBinding.cs
new file mode 100644
index 000000000..e19426d7b
--- /dev/null
+++ b/Assets/WL/Combat/Stage/StageBarrierBinding.cs
@@ -0,0 +1,76 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// StageBarrierBinding.cs — 815b 장벽 훅 ↔ 815c 장벽 구현을 잇는 **2줄**
+//
+// PD 지시 #815 · 발주서 WL-815d §1-1 (2026-09-11)
+// 근거 = 815b 보고 §3(계약 API 표 · `StageBarrierHook.Set/Clear`) · §6(「Lead 연결 2줄」)
+// 815c 보고 §1(「`StageBarrierHook` 은 정의하지 않았다 — Lead 가 2줄만 부르면 된다」)
+//
+// ■ 이 파일이 하는 일은 딱 이것뿐이다
+// StageBarrierHook.Set = StageBarrier.Set; // (Vector3 center, float radius)
+// StageBarrierHook.Clear = StageBarrier.Clear; // ()
+// 시그니처가 완전히 같아서 람다·어댑터가 필요 없다(815b §6 실측).
+// 둘 다 같은 네임스페이스 `WL.Combat.Stage` 라 using 도 필요 없다
+// (`StageBarrierHook` = `Stage/StageContracts.cs` · `StageBarrier` = `Stage/Barrier/StageBarrier.cs`).
+//
+// ■ 왜 별도 파일인가
+// 815b `StageDirector` / `StageDirectorRunner` 와 815c `Stage/Barrier/**` 를 **한 줄도 건드리지 않고**
+// 이음매만 새 파일로 둔다(발주서 §2 ⓑ). 이 파일을 지우면 장벽은 no-op 로 돌아간다 = 롤백 1파일.
+//
+// ■ C8 롤백 3단
+// ① 이 파일 삭제 또는 `StageBarrierBinding.Unbind()` → 훅 null = no-op(815b 계약)
+// ② `WLStageSettings.useBarrierHook = 0` → StageDirector 가 훅을 부르지 않는다
+// ③ `WLStageBarrierSettings.enabled = 0` → 815c 가 스스로 무동작(오브젝트 0)
+//
+// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 가 플레이어 팝업을 띄운다).
+// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
+// ─────────────────────────────────────────────────────────────────────────────
+
+using UnityEngine;
+
+namespace WL.Combat.Stage
+{
+ /// 815b 장벽 훅에 815c 구현을 대입한다(정적 · 오브젝트 0 · 값 0).
+ public static class StageBarrierBinding
+ {
+ /// 지금 훅이 815c 구현을 가리키고 있는가(프로브가 읽는다).
+ public static bool Bound { get; private set; }
+
+ /// 대입 횟수(진단).
+ public static int BindCount, UnbindCount;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
+ private static void Boot() { Bind(); }
+
+ /// 훅 2줄. 몇 번 불러도 결과가 같다(멱등).
+ public static string Bind()
+ {
+ StageBarrierHook.Set = StageBarrier.Set;
+ StageBarrierHook.Clear = StageBarrier.Clear;
+ Bound = true;
+ BindCount++;
+ return "장벽 훅 연결 — Set/Clear = WL.Combat.Stage.StageBarrier (bind " + BindCount + "회)";
+ }
+
+ /// 훅을 떼어 낸다(C8 ① · 프로브 A/B). 뗀 뒤에는 815b 가 값만 기록하고 아무 일도 하지 않는다.
+ public static string Unbind()
+ {
+ StageBarrierHook.Set = null;
+ StageBarrierHook.Clear = null;
+ Bound = false;
+ UnbindCount++;
+ return "장벽 훅 해제 — no-op 복귀(unbind " + UnbindCount + "회)";
+ }
+
+ /// 연결 상태 덤프(실측용).
+ public static string Dump()
+ {
+ return "[StageBarrierBinding] bound=" + Bound +
+ " hasSet=" + StageBarrierHook.HasSet + " hasClear=" + StageBarrierHook.HasClear +
+ " · 훅 호출 set=" + StageBarrierHook.SetCalls + " clear=" + StageBarrierHook.ClearCalls +
+ " faults=" + StageBarrierHook.Faults +
+ " · 마지막 " + StageBarrierHook.LastCenter.ToString("F1") + " r=" + StageBarrierHook.LastRadius.ToString("F1") +
+ " · 815c 실행 set=" + StageBarrier.SetCount + " clear=" + StageBarrier.ClearCount +
+ " active=" + StageBarrier.Active + " featureEnabled=" + StageBarrier.FeatureEnabled;
+ }
+ }
+}
diff --git a/Assets/WL/Combat/Stage/StageBarrierBinding.cs.meta b/Assets/WL/Combat/Stage/StageBarrierBinding.cs.meta
new file mode 100644
index 000000000..43b03be5b
--- /dev/null
+++ b/Assets/WL/Combat/Stage/StageBarrierBinding.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8d815d01a1b2c3d4e5f60708090a0c03
diff --git a/Assets/WL/UI/Scripts/ReviveDialog.cs b/Assets/WL/UI/Scripts/ReviveDialog.cs
index 04874f93e..9c836bdcf 100644
--- a/Assets/WL/UI/Scripts/ReviveDialog.cs
+++ b/Assets/WL/UI/Scripts/ReviveDialog.cs
@@ -53,6 +53,20 @@ namespace WL.UI
/// 팝업이 뜰 때 불린다(진단·튜토리얼용 · 선택).
public static System.Action ReviveShown;
+ // ── WL-815d 스테이지 실패 팝업 (기준서 §E) ───────────────────────────
+ // 스테이지 중 실패(사망 · 60 s 초과)는 「부활」 1버튼이 아니라 **「다시 도전 / 로비로」 2버튼**이다.
+ // · 815b `WLStageSettings.bindReviveOkToRetry` 는 **0** 으로 내려간다(임시 배선 제거).
+ // · 자동 부활 0(`WLSurvivalSettings.reviveWatchdogSeconds = 0`) 은 815b 가 이미 내려 두었다.
+ // · 813z 3층 방어(전용 Canvas sortingOrder 60 · 재보장 · 탈취 승격)는 **그대로 살아 있다**.
+ // · 공용 Popup 경로는 쓰지 않는다 — 버튼 문구 2개를 프리팹 수정 없이 넣을 수 없기 때문(813q 실측과 같은 이유).
+ /// [다시 도전] — 대입되어 있으면 [확인] 대신 이것이 불린다(815d `StageUiBridge`).
+ public static System.Action PrimaryOverride;
+ /// [로비로] — 대입되어 있으면 두 번째 버튼이 생긴다.
+ public static System.Action SecondaryAction;
+ /// 지금 스테이지 실패 팝업 모드인가(문구·버튼 2개).
+ public static bool StageMode { get; private set; }
+ public static int StagePrimaryCount, StageSecondaryCount, StageShownCount;
+
/// 현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다).
public static ReviveDialog Active { get; private set; }
@@ -96,6 +110,9 @@ namespace WL.UI
[SerializeField] private TextMeshProUGUI messageLabel;
[SerializeField] private Button okButton;
[SerializeField] private TextMeshProUGUI okLabel;
+ // WL-815d — 스테이지 실패 팝업의 두 번째 버튼(기준서 §E 「다시 도전 / 로비로」)
+ [SerializeField] private Button secondaryButton;
+ [SerializeField] private TextMeshProUGUI secondaryLabel;
[Header("폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset font;
@@ -108,6 +125,8 @@ namespace WL.UI
// ── WL-813z(D-3) 최상단 보장 ──────────────────────────────────────────
/// 런타임 생성 확인 버튼 이름 — 원본 `NightmareResult/btn_ok` 와 겹치지 않게 접두어를 붙인다(D-9).
public const string OkButtonName = "WL_btn_ok";
+ /// WL-815d — 두 번째 버튼 이름(같은 D-9 규약으로 `WL_` 접두).
+ public const string SecondaryButtonName = "WL_btn_second";
private Canvas _ownCanvas;
private GraphicRaycaster _ownRaycaster;
private float _nextTopmostAt = -1f;
@@ -263,7 +282,8 @@ namespace WL.UI
EnsureTopmost(g);
_nextTopmostAt = Time.unscaledTime + (g != null ? Mathf.Max(0f, g.reviveTopmostIntervalSeconds) : 0f);
- if (s.reviveUseCommonPopup)
+ // 🔴 WL-815d — 스테이지 실패는 버튼이 2개라 공용 Popup(문구 1개 · 프리팹 고정 버튼)으로 못 만든다.
+ if (s.reviveUseCommonPopup && !StageMode)
{
string r = TryCommonPopup(s, msg);
if (UsedCommonPopup)
@@ -281,6 +301,8 @@ namespace WL.UI
/// 본문 = 설정 문자열(로컬라이즈되면 reviveMessageKey 우선) + 비용 줄.
public string BuildMessage(WLSurvivalUiSettings s)
{
+ // WL-815d — 스테이지 실패 문구(사망 / 시간 초과)가 있으면 그것이 이긴다(비용 줄 없음).
+ if (StageMode && !string.IsNullOrEmpty(_msgOverride)) return _msgOverride;
string msg = s.reviveMessage;
if (s.reviveMessageKey > 0)
{
@@ -319,16 +341,74 @@ namespace WL.UI
EnsureTopmost(WLSurvivalSettings.Instance); // WL-813z(D-3) — 승격 직후에도 최상단·레이캐스트 보장
}
- /// 확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다.
+ /// 확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다.
+ /// 🔴 WL-815d: 스테이지 실패 팝업이면 [다시 도전](`StageDirector.Retry()`)이 대신 불린다.
public string OnConfirm()
{
ConfirmCount++;
+ bool stage = StageMode && PrimaryOverride != null;
HideNow();
+ if (stage)
+ {
+ StagePrimaryCount++;
+ try { PrimaryOverride(); } catch { }
+ return "다시 도전 — StageDirector.Retry() 전달";
+ }
if (ReviveRequested == null) return "확인 — 813i(ReviveRequested) 미연결이라 부활 동작 없음(후속)";
try { ReviveRequested(); } catch { }
return "확인 — 부활 요청 전달";
}
+ /// WL-815d — 두 번째 버튼([로비로]).
+ public string OnSecondary()
+ {
+ HideNow();
+ if (SecondaryAction == null) return "두 번째 버튼 — 핸들러 미연결(무동작)";
+ StageSecondaryCount++;
+ try { SecondaryAction(); } catch { }
+ return "로비로 — 전달";
+ }
+
+ // ── WL-815d 스테이지 실패 팝업 진입점 ────────────────────────────────
+ private string _msgOverride, _okOverride, _secondOverride;
+
+ ///
+ /// 스테이지 실패 팝업을 **지금** 연다(2버튼 · 전용 폴백 패널 고정 · 공용 Popup 미사용).
+ /// 사망 경로(DeathFlow → NotifyReviveRequested)로 이미 떠 있으면 문구·버튼만 갈아 끼운다.
+ ///
+ public string ShowStageFail(string message, string okText, string secondaryText)
+ {
+ var s = WLSurvivalUiSettings.Instance;
+ if (s == null) return "WLSurvivalUiSettings 에셋 없음";
+ StageMode = true;
+ _msgOverride = message; _okOverride = okText; _secondOverride = secondaryText;
+ _showAt = -1f;
+ _open = true;
+ StageShownCount++;
+ ShownCount++;
+ LastMessage = message ?? "";
+ UsedCommonPopup = false; // 🔴 2버튼은 공용 Popup 으로 못 만든다 → 전용 패널
+ _escalated = false;
+
+ var g = WLSurvivalSettings.Instance;
+ EnsureTopmost(g); // 813z 3층 방어 그대로
+ _nextTopmostAt = Time.unscaledTime + (g != null ? Mathf.Max(0f, g.reviveTopmostIntervalSeconds) : 0f);
+
+ ShowFallback(s, LastMessage);
+ if (ReviveShown != null) { try { ReviveShown(); } catch { } }
+ return "스테이지 실패 팝업 2버튼 표시 · msg=\"" + LastMessage.Replace("\n", " / ") +
+ "\" [" + okText + "] [" + secondaryText + "] · sortingOrder=" + SortingOrder;
+ }
+
+ /// 스테이지 실패 팝업 모드를 끈다(813z 원래 1버튼 부활 팝업으로 복귀).
+ public string ClearStageMode()
+ {
+ StageMode = false;
+ _msgOverride = _okOverride = _secondOverride = null;
+ ApplyLayout();
+ return "스테이지 실패 팝업 모드 해제 — 813z 원래대로(버튼 1개)";
+ }
+
public void HideNow()
{
_showAt = -1f;
@@ -417,12 +497,40 @@ namespace WL.UI
if (font != null) okLabel.font = font;
made = true;
}
+ // WL-815d — 두 번째 버튼(스테이지 실패 「로비로」). 평소에는 SetActive(false) 라 813z 화면은 그대로다.
+ if (secondaryButton == null)
+ {
+ var t = WLVignetteUtil.NewChild(box, SecondaryButtonName);
+ var img = t.GetComponent();
+ if (img == null) img = t.gameObject.AddComponent();
+ img.raycastTarget = true;
+ secondaryButton = t.GetComponent