Merge branch 'wl/ui/WL-815d-stage-ui'

This commit is contained in:
깃 관리자 2026-09-11 01:52:30 +09:00
commit 20d24d8a1e
15 changed files with 2781 additions and 26 deletions

View File

@ -0,0 +1,210 @@
# WL815d Probe 2026-09-11 01:46:18 (edit mode · Play 0 · 로그인 0)
playMode=False
── ⓪ 에셋 · SO 값 (전부 C45)
원본 프리팹 기준선: Assets/Res_Addr/MainUI/NewGameUI.prefab 5361935B · 2026-09-08 17:05:53Z
[PASS] Resources.Load("WL/WLStageUiSettings") — NEW 에셋
[PASS] WLStageUiSettings.Enabled = True
HUD: stageLabel="스테이지 {0}" enemy="남은 적 {0} / {1}" warn=10s countdown="준비" 존게이지숨김=True
메뉴: hide=True 목록 11개 · 유지 표기 7개 · 복원=True · 룩토글유지=True
결과: 제목="스테이지 {0} 클리어!" 행 6개 [ClearTime · Kills · EliteKills · MaxChain · HitsPotions · Retries] · 버튼 [다음 스테이지] [로비로] · 배지="무피격 클리어!"
실패: [다시 도전] [로비로] · 사망="쓰러졌습니다. / 다시 도전하시겠습니까?" · 시간초과="시간 초과! / 남은 적 {0}마리" · 결과창억제=True
로비: lobbyMode=ReturnToLobby titleScene="Title"
[PASS] 타이머 경고 = 마지막 10 s(PD 60 s 규칙) — 실측 10
[PASS] 815b WLStageSettings 존재
[PASS] F-27 autoEnterOnMapEnter = 1 (맵 진입 = 스테이지 1) — 실측 True
[PASS] bindReviveOkToRetry = 0 (815d 2버튼 팝업이 대신한다) — 실측 False
[PASS] toLobbyCallsReturnToLobby = 1 (로비 ⓐ안) — 실측 True
[PASS] 장벽 훅 사용 on/off = True/True
기타: mirrorRunEnded=True mirrorRunHudEvents=True clearHoldScale=0 wrapToFirstStage=False
[PASS] 자동 부활 0 (reviveWatchdogSeconds) — 실측 0
815c 장벽: style=Props(0 None/1 Ring/2 Props/3 Fire) enabled=True showVisual=True blockEnabled=True
[PASS] F-26 style = 2(Props · 울타리) — 실측 2
── ① 장벽 훅 — StageBarrierHook.Set/Clear = StageBarrier.Set/Clear
장벽 훅 연결 — Set/Clear = WL.Combat.Stage.StageBarrier (bind 1회)
[PASS] StageBarrierHook.Set 대입됨
[PASS] StageBarrierHook.Clear 대입됨
[PASS] 훅 Set 호출 1 — 실측 1
[PASS] 815c StageBarrier.Set 실제 실행 +1 — 실측 1
[PASS] 장벽 활성 = True 중심 (115.0, 0.4, -22.0) r=14.0
[PASS] 815c StageBarrier.Clear 실제 실행 +1 — 실측 1
[PASS] 장벽 해제 = True
[PASS] 훅 예외 0 — 실측 0
StageDirector.Enter(0) = True · 상태 Entering · 고정 수 8 · 제한 60s
[PASS] 입장 시 훅 Set 호출 ≥1 — 실측 1 중심 (115.0, 0.4, -22.0) r=14.0
[PASS] 이탈 시 훅 Clear 호출 ≥1 — 실측 1
장벽 훅 해제 — no-op 복귀(unbind 1회)
[PASS] Unbind 뒤 815c 실행 0 = no-op(C8 ①)
장벽 훅 연결 — Set/Clear = WL.Combat.Stage.StageBarrier (bind 2회)
[StageBarrierBinding] bound=True hasSet=True hasClear=True · 훅 호출 set=2 clear=1 faults=0 · 마지막 (115.0, 0.4, -22.0) r=14.0 · 815c 실행 set=2 clear=2 active=False featureEnabled=True
── ② 노드 — NewGameUI 프리팹(저장 0)에 HUD·결과·실패 팝업 조립
[PASS] LoadPrefabContents(Assets/Res_Addr/MainUI/NewGameUI.prefab)
Bind HUD : 연결 IngameUIs/WL_HUD · pos(0.00, -483.56) size(995.56, 167.11) 칸=0 스테이지모드=False 존게이지=True 남은적=False unitsPerPx=1.7778
Bind 결과 : 연결 IngameUIs/WL_HUD · box size(79.63, 100.00) 행틀=11 스테이지모드=False 버튼=[다음 런](True) [닫기] 배지=False unitsPerPx=0.0926 · sortingOrder=-1
[PASS] 813q 노드 조립(WL_RunHud · WL_RunResult · HideFlags.DontSave)
[PASS] StageUiBridge 구독 on
[PASS] StageEvents 구독 배선 — Entered 1 Countdown 1 Started 1 Enemy 1 Time 1 Cleared 1 Failed 1 Exited 1
[PASS] 813z ReviveDialog 컴포넌트 발견(IngameUIs/WL_HUD/WL_ReviveDialog)
ReviveDialog init: initialized · 폴백 팝업 box size(1351.11, 675.56) 버튼=1 [부활] unitsPerPx=1.7778
[PASS] ReviveDialog.Active 세팅
── ③ 상단 3요소 — 스테이지 N · 남은 시간 mm:ss · 남은 적 M/T (+ 카운트다운)
[PASS] StageEntered 수신 1
[PASS] RunHud 스테이지 모드 on
[PASS] ① 단계 = "스테이지 1" — 실측 "스테이지 1"
[PASS] ② 타이머 = 01:00(제한 60 s) — 실측 "01:00"
[PASS] ③ 남은 적 = "남은 적 8 / 8" — 실측 "남은 적 8 / 8"
[PASS] 존 게이지 접힘(기준서 §F-2) — 실측 활성=False
[PASS] 카운트다운 = "준비" 표시 — 실측 보임=True "준비"
[PASS] 전투 시작 = 카운트다운 줄 접힘 — 실측 False
[PASS] 처치 3 → "남은 적 5 / 8" — 실측 "남은 적 5 / 8"
[PASS] 42 s → 00:42 · 평상색 — 실측 "00:42" warn=False
[PASS] 마지막 10 s → 붉은색(경고) — 실측 "00:10" warn=True color=#FF4A47
[PASS] 남은 적 2 → 강조색 — 실측 "남은 적 2 / 8"
[PASS] 병행 RunEvents 2건 무시 — 실측 2
[PASS] 🔴 병행 발행이 상단 3요소를 덮지 않는다 — "스테이지 1" "00:10" "남은 적 2 / 8"
HUD 배치: pos(0.00, -483.56) size(995.56, 245.33) 칸=0 스테이지모드=True 존게이지=False 남은적=True unitsPerPx=1.7778
[RunHud] 구독=True bound=True visible=True · started=1 tick=1 zoneCleared=0 gate=0 boss=0 ended=0
타이머 "00:10" (남은 9.4s · 경고=True · 쓰기 3회) · 단계 "스테이지 1" (쓰기 0회)
[813q2] 타이머 색 = FF4A47 (색 쓰기 2회 · 다음런리셋대기=False · 기본 F2F5FA / 경고 FF4A47)
존 칸 0 (fill 쓰기 0회):
노드 pos(0.00, -483.56) size(995.56, 245.33) active=True 부모=WL_HUD SafeArea=True
[815d] 스테이지모드=True 단계="스테이지 1"(쓰기 1) 남은적="남은 적 2 / 8"(쓰기 3) 알림="준비"(보임 False · 쓰기 2) · 무시한 RunEvents=2 · 존게이지활성=False · 경고초=10s
edge = Font SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.280 outlineC=0D0805FF underlay=000000CC off(0.50,-0.50) soft=0.22 UNDERLAY_ON=True
설정 enabled=True hud=True topMargin=272px width=560px timerPx=56 warn=30s pulse=1.35×0.3s
── ④ 불필요 메뉴 숨김 · 복원 (§J ① · 원본 프리팹 diff 0)
[PASS] 숨김 목록 11개 전부 실존(경로 해석 성공) — 못 찾음 0
숨김: 메뉴 숨김 — 경로 11개 중 찾음 11 · 실제로 끈 것 11 · 못 찾음 0
[PASS] 원본이 다시 켠 3건을 재보장으로 도로 끔 — 실측 3건 · 남은 on 0 (주기 0.5s)
[PASS] 이미 꺼져 있으면 쓰기 0회(GC 0)
복원: 메뉴 복원 11건
| # | 경로(NewGameUI 루트 기준) | 실제 노드 경로 | 프리팹 저장 | 스테이지 중 | 이탈 뒤 |
|---|---|---|---|---|---|
| 0 | `Common/GuideQuest` | `WL_SafeArea_Back/Common/GuideQuest` | off | **off** | **on** |
| 1 | `Common/TopRightBtns` | `WL_SafeArea_Back/Common/TopRightBtns` | off | **off** | **on** |
| 2 | `Common/TopRightMoney` | `WL_SafeArea_Back/Common/TopRightMoney` | off | **off** | **on** |
| 3 | `Common/Lv` | `WL_SafeArea_Back/Common/Lv` | off | **off** | **on** |
| 4 | `IngameUIs/WL_HUD/HUD_Currency` | `IngameUIs/WL_HUD/HUD_Currency` | off | **off** | **on** |
| 5 | `IngameUIs/WL_HUD/HUD_Level` | `IngameUIs/WL_HUD/HUD_Level` | off | **off** | **on** |
| 6 | `IngameUIs/WL_HUD/HUD_BottomMenu` | `IngameUIs/WL_HUD/HUD_BottomMenu` | off | **off** | **on** |
| 7 | `IngameUIs/Stage/exit` | `IngameUIs/Stage/exit` | off | **off** | **on** |
| 8 | `IngameUIs/Stage/btn_setting` | `IngameUIs/Stage/btn_setting` | off | **off** | **on** |
| 9 | `IngameUIs/MonsterSimpleInfo` | `IngameUIs/MonsterSimpleInfo` | off | **off** | **on** |
| 10 | `IngameUIs/IngamePetCard` | `IngameUIs/IngamePetCard` | off | **off** | **on** |
[PASS] 스테이지 중 전부 SetActive(false) — 11/11
[PASS] 이탈 시 전부 켜진 값으로 복원 — 11/11
(유지) IngameUIs/BattleUI activeSelf=True
(유지) Common/MyInfoUI activeSelf=True
(유지) IngameUIs/WL_HUD/WL_BossHpBar activeSelf=True
(유지) IngameUIs/WL_HUD/WL_KillChainText activeSelf=True
(유지) IngameUIs/WL_HUD/WL_LootToast activeSelf=True
(유지) IngameUIs/WL_HUD/WL_HitVignette activeSelf=True
(유지) IngameUIs/WL_HUD/WL_ReviveDialog activeSelf=True
[PASS] 유지 목록 7개 전부 실존 — 없음 0
유지 확인 7개 (조이스틱·스킬 패드 BattleUI · HP/MP MyInfoUI · 813 WL HUD 요소)
[PASS] 원본 NewGameUI.prefab 파일 무변경 — 길이 5361935B(=5361935) 수정시각 17:05:53(=17:05:53)
(참고) 미리보기 사본 dirty=True — 런타임 노드는 HideFlags.DontSave 라 저장해도 굽히지 않는다(813y 규약)
── ⑤ 클리어 화면 — StageCleared → 813q RunResultUI (기준서 §F-1)
[PASS] Damaged(victim=null)는 피격으로 세지 않는다 — 실측 0
[PASS] 피격 3회 집계 — 실측 3
[PASS] 최대 연쇄 = 5(3 은 갱신 안 함) — 실측 5
[PASS] StageCleared 수신 1
[PASS] 결과 화면 표시 — 실측 open=True
[PASS] 결과창 스테이지 모드 on
[PASS] 제목 = "스테이지 1 클리어!" — 실측 "스테이지 1 클리어!"
본문 =
스테이지 1 클리어!
클리어 시간 : 00:35
처치 : 8 / 8
엘리트 처치 : 0
최대 연쇄 : 5
피격 / 물약 : 3회 / 0개
재도전 : 1회
[PASS] 행 6개 — 실측 6
[PASS] 피격 3회 → 무피격 배지 없음 — 실측 False
배치: box size(860.00, 1080.00) 행틀=11 스테이지모드=True 버튼=[다음 스테이지](True) [로비로] 배지=False unitsPerPx=1.0000
[PASS] 🔴 병행 RunEnded(Win) 로 결과창 중복 표시 0 — 실측 +0 · 억제 1회
[PASS] 제목이 「런 클리어!」로 덮이지 않았다 — "스테이지 1 클리어!"
[PASS] 피격 0회 → 무피격 배지 — 실측 True "무피격 클리어!"
[PASS] 마지막 스테이지 제목 — 실측 "전 스테이지 클리어!"
마지막 스테이지 배치: box size(860.00, 1080.00) 행틀=11 스테이지모드=True 버튼=[처음부터](True) [로비로] 배지=True unitsPerPx=1.0000
본문(보스 스테이지) =
전 스테이지 클리어!
클리어 시간 : 00:55
처치 : 15 / 15
엘리트 처치 : 0
최대 연쇄 : 0
피격 / 물약 : 0회 / 0개
재도전 : 2회
무피격 클리어!
── ⑥ 실패 팝업 — StageFailed → 813z ReviveDialog 2버튼 (기준서 §E)
[PASS] StageFailed 수신 1
[PASS] 실패 팝업 표시 — 실측 open=True
[PASS] ReviveDialog 스테이지 모드 on
[PASS] 공용 Popup 미사용(2버튼 불가) — 실측 False
배치: 폴백 팝업 box size(1351.11, 675.56) 버튼=2 [다시 도전] [로비로] unitsPerPx=1.7778
[PASS] 두 번째 버튼 노출 — 실측 True
[PASS] 버튼 = [다시 도전] [로비로] — 실측 [다시 도전] [로비로]
[PASS] 시간 초과 문구 + 남은 적 3 — 실측 "시간 초과! / 남은 적 3마리"
[PASS] 813z 3층 방어 유지 — SO sortingOrder 60 · 최상단 재보장 2회 · 실측 게터=-1(부모 계층 비활성 → Unity 가 overrideSorting 되돌림 · 814d §4)
[PASS] 실패 시점에 결과창 0
[PASS] 🔴 병행 RunEnded(Timeout) 억제 → 결과창 안 뜸 — 실측 open=False 억제 1회
[PASS] 사망 문구 구분 — 실측 "쓰러졌습니다. / 다시 도전하시겠습니까?"
[PASS] 사망 실패도 2버튼 모드 — 실측 True
클릭 [다시 도전] : 다시 도전 — StageDirector.Retry() 전달
[PASS] [다시 도전] → StageDirector.Retry() 1회 — 실측 +1
[PASS] 누르면 팝업 닫힘 — 실측 open=False
클릭 [로비로] : 로비로 — 전달
[PASS] [로비로] → StageDirector.ToLobby() 1회 — 실측 +1
[ReviveDialog] deaths=0 shows=3 confirms=1 대기중=False 공용팝업사용=False 폴백표시=False open=False 813i(ReviveRequested)=False 브리지=False
본문="쓰러졌습니다. / 다시 도전하시겠습니까?"
[815d] 스테이지모드=False 버튼=[부활] (두번째 없음) · 핸들러 primary=True secondary=True · 클릭 다시도전=1 로비=1 · 표시 3회
공용 Popup.Ins=없음(에디트 모드/씬 미로드)
최상단 = sortingOrder -1(SO 60) · 맨뒤형제=True · 재보장=6회 · 탈취감지=0 · 승격=False · 공용팝업닫음=0 · raycaster=True · blocksRaycasts=False
워치독 = 0.0s · 무시disable=True · 최상단ON=True 주기=0.50s · DeathFlow 발동=0회 대기=0.00s
폴백 box size(1351.11, 675.56) pos(0.00, 0.00) ok="부활" okName=WL_btn_ok
설정 enabled=True 공용팝업=True delay=2s key=0 비용="무료"
── ⑦ 클리어 화면 버튼 → StageDirector.Next() / ToLobby()
클릭 [다음 스테이지] : 다음 스테이지 — StageDirector 호출
[PASS] [다음 스테이지] → StageDirector.Next() 1회 — 실측 +1
[PASS] 누르면 결과창 닫힘
클릭 [로비로] : 로비로 — StageDirector 호출
[PASS] [로비로] → StageDirector.ToLobby() 1회 — 실측 +1
로비 동작: 로비로 — ReturnToLobby (toLobbyCallsReturnToLobby=1)
클릭 [처음부터] : 다음 스테이지 — StageDirector 호출
[PASS] [처음부터] → StageDirector.Enter(0) 1회 — 실측 +1
[PASS] 이탈 → RunHud 스테이지 모드 off
[PASS] 이탈 → 결과창 스테이지 모드 off
[PASS] 이탈 → 실패 팝업 모드 off(813z 원래 1버튼)
[StageUiBridge] 구독=True inStage=False 스테이지=3 · 입장 3 카운트 0 시작 0 적수 3 시간 0 클리어 3 실패 0 이탈 2
실측 — 피격 0회 · 물약 0개(시작 스냅 0 / 현재 0) · 최대 연쇄 0(진행중 0)
메뉴 — 숨김 3회 / 복원 2회 / 재보장 0회(도로 끈 것 0건 · 주기 0.50s) · 지금 숨긴 것 0건 · 못 찾은 경로 0
버튼 — 다시도전 0 다음 2 처음부터 0 로비 1 · 로비모드=ReturnToLobby · toLobbyCallsReturnToLobby=1
설정 enabled=True hud=True 메뉴숨김=True 결과=True 실패팝업=True · 마지막 로그 = 이탈(probe lobby) — 로비=True · 메뉴 복원 0건
── ⑧ GC — 같은 값 100틱 = 표시 쓰기 0회
[PASS] 같은 초 100틱 → 타이머 쓰기 0회 — 실측 +0
[PASS] 같은 수 100틱 → 남은 적 쓰기 0회 — 실측 +0
[PASS] 같은 스테이지 → 라벨 쓰기 0회 — 실측 +0
ΔGetTotalMemory = 0 B (에디터 TMP 문자열 포함 · 빌드에는 없는 줄)
── ⑨ C8 — WLStageUiSettings 를 끄면 813q/813z 가 원래대로
[PASS] RuntimeDisabled → Enabled=false
[PASS] 축 off → 스테이지 모드 0 — 실측 False
[PASS] 축 off → 메뉴 숨김 0 — 실측 0
[PASS] 축 off → 결과창 스테이지 모드 0
[PASS] 축 off → 실패 팝업 2버튼 0(813z 원래 부활 팝업)
[PASS] 813q WLRunUiSettings.Enabled 불변 = True
[PASS] 813q 런 HUD 원래대로 — 실측 "04:00" "존 1"
[PASS] 복구 → Enabled=true
RESULT PASS (pass=94 fail=0)

View File

@ -0,0 +1,714 @@
// WL-815d 프로브 — 스테이지 UI 실측 (에디트 모드 · Play 0 · 로그인 0).
// unity command run_script --file AgentScripts/WL815d_Probe.cs --entry WL815d_Probe.RunAll
// unity command run_script --file AgentScripts/WL815d_Probe.cs --entry WL815d_Probe.Capture
// 산출물: <worktree>/AgentScripts/WL815d_PROBE.txt · Screenshots_WL/WL815d/*.png
// 🔴 NewGameUI.prefab 은 LoadPrefabContents 로 열고 **저장하지 않는다**(UnloadPrefabContents(false)).
using System;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEditor;
using TMPro;
using WL.UI;
using WL.Combat.Survival;
public static class WL815d_Probe
{
const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab";
static StringBuilder _o;
static int _pass, _fail;
static GameObject _root;
static long _prefabLen;
static DateTime _prefabTime;
static void H(string s) { _o.AppendLine(); _o.AppendLine("── " + s); }
static void N(string s) { _o.AppendLine(" " + s); }
static bool Chk(bool ok, string what)
{
if (ok) { _pass++; _o.AppendLine(" [PASS] " + what); }
else { _fail++; _o.AppendLine(" [FAIL] " + what); }
return ok;
}
static string Root { get { return Directory.GetParent(Application.dataPath).FullName; } }
public static string RunAll()
{
_o = new StringBuilder();
_pass = _fail = 0;
_o.AppendLine("# WL815d Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)");
_o.AppendLine("playMode=" + Application.isPlaying);
bool savedStageUiDisabled = WLStageUiSettings.RuntimeDisabled;
try
{
Step0_Assets();
Step1_BarrierHook();
Step2_Nodes();
Step3_HudThreeItems();
Step4_Menus();
Step5_Clear();
Step6_Fail();
Step7_Counters();
Step8_Gc();
Step9_C8();
}
catch (Exception e)
{
_fail++;
_o.AppendLine(" [FAIL] 예외 " + e.GetType().Name + " : " + e.Message);
_o.AppendLine(e.StackTrace);
}
finally
{
WLStageUiSettings.RuntimeDisabled = savedStageUiDisabled;
Cleanup();
}
_o.AppendLine();
_o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " (pass=" + _pass + " fail=" + _fail + ")");
string path = Path.Combine(Path.Combine(Root, "AgentScripts"), "WL815d_PROBE.txt");
File.WriteAllText(path, _o.ToString(), new UTF8Encoding(false));
return _o.ToString() + "\n(파일: " + path + ")";
}
// ─────────────────────────────────────────────── ⓪ 에셋 · SO 값
static void Step0_Assets()
{
H("⓪ 에셋 · SO 값 (전부 C45)");
var pf = new FileInfo(Path.Combine(Root, kPrefab.Replace('/', Path.DirectorySeparatorChar)));
_prefabLen = pf.Length; _prefabTime = pf.LastWriteTimeUtc;
N("원본 프리팹 기준선: " + kPrefab + " " + _prefabLen + "B · " + _prefabTime.ToString("yyyy-MM-dd HH:mm:ss") + "Z");
WLStageUiSettings.ClearCache();
var g = WLStageUiSettings.Instance;
Chk(g != null, "Resources.Load(\"" + WLStageUiSettings.ResourcesPath + "\") — NEW 에셋");
if (g == null) return;
Chk(WLStageUiSettings.Enabled, "WLStageUiSettings.Enabled = " + WLStageUiSettings.Enabled);
N("HUD: stageLabel=\"" + g.stageLabelFormat + "\" enemy=\"" + g.enemyLabelFormat + "\" warn=" +
g.timerWarnSeconds + "s countdown=\"" + g.countdownText + "\" 존게이지숨김=" + g.hideZoneGaugeInStage);
N("메뉴: hide=" + g.hideMenusInStage + " 목록 " + (g.stageHidePaths != null ? g.stageHidePaths.Length : 0) +
"개 · 유지 표기 " + (g.stageKeepPaths != null ? g.stageKeepPaths.Length : 0) + "개 · 복원=" + g.restoreMenusOnExit +
" · 룩토글유지=" + g.keepLookToggleButtons);
N("결과: 제목=\"" + g.stageClearTitleFormat + "\" 행 " + (g.stageResultRows != null ? g.stageResultRows.Length : 0) +
"개 [" + string.Join(" · ", Array.ConvertAll(g.stageResultRows, r => r.ToString())) + "] · 버튼 [" +
g.nextStageText + "] [" + g.toLobbyText + "] · 배지=\"" + g.noHitBadgeText + "\"");
N("실패: [" + g.failRetryText + "] [" + g.failLobbyText + "] · 사망=\"" + g.failDeathMessage.Replace("\n", " / ") +
"\" · 시간초과=\"" + g.failTimeoutMessage.Replace("\n", " / ") + "\" · 결과창억제=" + g.suppressResultOnFail);
N("로비: lobbyMode=" + g.lobbyMode + " titleScene=\"" + g.titleSceneName + "\"");
Chk(Mathf.Approximately(g.timerWarnSeconds, 10f), "타이머 경고 = 마지막 10 s(PD 60 s 규칙) — 실측 " + g.timerWarnSeconds);
var c = WL.Combat.Stage.WLStageSettings.Instance;
Chk(c != null, "815b WLStageSettings 존재");
if (c != null)
{
Chk(c.autoEnterOnMapEnter, "F-27 autoEnterOnMapEnter = 1 (맵 진입 = 스테이지 1) — 실측 " + c.autoEnterOnMapEnter);
Chk(!c.bindReviveOkToRetry, "bindReviveOkToRetry = 0 (815d 2버튼 팝업이 대신한다) — 실측 " + c.bindReviveOkToRetry);
Chk(c.toLobbyCallsReturnToLobby, "toLobbyCallsReturnToLobby = 1 (로비 ⓐ안) — 실측 " + c.toLobbyCallsReturnToLobby);
Chk(c.useBarrierHook && c.clearBarrierOnExit, "장벽 훅 사용 on/off = " + c.useBarrierHook + "/" + c.clearBarrierOnExit);
N("기타: mirrorRunEnded=" + c.mirrorRunEnded + " mirrorRunHudEvents=" + c.mirrorRunHudEvents +
" clearHoldScale=" + c.clearHoldScale + " wrapToFirstStage=" + c.wrapToFirstStage);
}
var sv = WLSurvivalSettings.Instance;
if (sv != null) Chk(Mathf.Approximately(sv.reviveWatchdogSeconds, 0f),
"자동 부활 0 (reviveWatchdogSeconds) — 실측 " + sv.reviveWatchdogSeconds);
var bs = WL.Combat.Stage.WLStageBarrierSettings.Instance;
if (bs != null)
{
N("815c 장벽: style=" + bs.style + "(0 None/1 Ring/2 Props/3 Fire) enabled=" + bs.enabled +
" showVisual=" + bs.showVisual + " blockEnabled=" + bs.blockEnabled);
Chk((int)bs.style == 2, "F-26 style = 2(Props · 울타리) — 실측 " + (int)bs.style);
}
}
// ─────────────────────────────────────────────── ① 장벽 훅 2줄
static void Step1_BarrierHook()
{
H("① 장벽 훅 — StageBarrierHook.Set/Clear = StageBarrier.Set/Clear");
N(WL.Combat.Stage.StageBarrierBinding.Bind());
Chk(WL.Combat.Stage.StageBarrierHook.HasSet, "StageBarrierHook.Set 대입됨");
Chk(WL.Combat.Stage.StageBarrierHook.HasClear, "StageBarrierHook.Clear 대입됨");
WL.Combat.Stage.StageBarrierHook.ResetDiagnostics();
int set0 = WL.Combat.Stage.StageBarrier.SetCount, clr0 = WL.Combat.Stage.StageBarrier.ClearCount;
var center = new Vector3(115f, 0.4f, -22f);
WL.Combat.Stage.StageBarrierHook.RaiseSet(center, 14f);
Chk(WL.Combat.Stage.StageBarrierHook.SetCalls == 1, "훅 Set 호출 1 — 실측 " + WL.Combat.Stage.StageBarrierHook.SetCalls);
Chk(WL.Combat.Stage.StageBarrier.SetCount == set0 + 1,
"815c StageBarrier.Set 실제 실행 +1 — 실측 " + (WL.Combat.Stage.StageBarrier.SetCount - set0));
Chk(WL.Combat.Stage.StageBarrier.Active, "장벽 활성 = " + WL.Combat.Stage.StageBarrier.Active +
" 중심 " + WL.Combat.Stage.StageBarrier.Center.ToString("F1") + " r=" + WL.Combat.Stage.StageBarrier.Radius.ToString("F1"));
WL.Combat.Stage.StageBarrierHook.RaiseClear();
Chk(WL.Combat.Stage.StageBarrier.ClearCount == clr0 + 1,
"815c StageBarrier.Clear 실제 실행 +1 — 실측 " + (WL.Combat.Stage.StageBarrier.ClearCount - clr0));
Chk(!WL.Combat.Stage.StageBarrier.Active, "장벽 해제 = " + (!WL.Combat.Stage.StageBarrier.Active));
Chk(WL.Combat.Stage.StageBarrierHook.Faults == 0, "훅 예외 0 — 실측 " + WL.Combat.Stage.StageBarrierHook.Faults);
// 실입장 경로(815b StageDirector) 가 훅을 부르는가
WL.Combat.Stage.StageBarrierHook.ResetDiagnostics();
WL.Combat.Stage.StageDirector.ResetAll();
bool entered = false;
try { entered = WL.Combat.Stage.StageDirector.Enter(0, "815d probe"); }
catch (Exception e) { N("Enter 예외(에디트 모드) " + e.GetType().Name + " : " + e.Message); }
N("StageDirector.Enter(0) = " + entered + " · 상태 " + WL.Combat.Stage.StageDirector.State +
" · 고정 수 " + WL.Combat.Stage.StageDirector.TotalEnemies + " · 제한 " + WL.Combat.Stage.StageDirector.TimeLimit + "s");
Chk(WL.Combat.Stage.StageBarrierHook.SetCalls >= 1,
"입장 시 훅 Set 호출 ≥1 — 실측 " + WL.Combat.Stage.StageBarrierHook.SetCalls +
" 중심 " + WL.Combat.Stage.StageBarrierHook.LastCenter.ToString("F1") +
" r=" + WL.Combat.Stage.StageBarrierHook.LastRadius.ToString("F1"));
WL.Combat.Stage.StageDirector.ToLobby();
Chk(WL.Combat.Stage.StageBarrierHook.ClearCalls >= 1,
"이탈 시 훅 Clear 호출 ≥1 — 실측 " + WL.Combat.Stage.StageBarrierHook.ClearCalls);
// C8 ① — 훅을 떼면 no-op
N(WL.Combat.Stage.StageBarrierBinding.Unbind());
int s1 = WL.Combat.Stage.StageBarrier.SetCount;
WL.Combat.Stage.StageBarrierHook.RaiseSet(center, 14f);
Chk(WL.Combat.Stage.StageBarrier.SetCount == s1, "Unbind 뒤 815c 실행 0 = no-op(C8 ①)");
N(WL.Combat.Stage.StageBarrierBinding.Bind());
WL.Combat.Stage.StageBarrier.Clear();
N(WL.Combat.Stage.StageBarrierBinding.Dump());
}
// ─────────────────────────────────────────────── ② 노드
static void Step2_Nodes()
{
H("② 노드 — NewGameUI 프리팹(저장 0)에 HUD·결과·실패 팝업 조립");
var root = PrefabUtility.LoadPrefabContents(kPrefab);
_root = root;
Chk(root != null, "LoadPrefabContents(" + kPrefab + ")");
if (root == null) return;
StageUiBridge.SetUiRootForProbe(root.transform);
RunHud.Subscribe(true);
RunResultUI.Subscribe(true);
StageUiBridge.Subscribe(true);
N("Bind HUD : " + RunHud.Bind(root.transform));
N("Bind 결과 : " + RunResultUI.Bind(root.transform));
Chk(RunHud.Bound && RunResultUI.Bound, "813q 노드 조립(WL_RunHud · WL_RunResult · HideFlags.DontSave)");
Chk(StageUiBridge.Subscribed, "StageUiBridge 구독 on");
Chk(WL.Combat.Stage.StageEvents.StageEntered.Count >= 1 && WL.Combat.Stage.StageEvents.StageCleared.Count >= 1 &&
WL.Combat.Stage.StageEvents.StageFailed.Count >= 1 && WL.Combat.Stage.StageEvents.StageExited.Count >= 1,
"StageEvents 구독 배선 — Entered " + WL.Combat.Stage.StageEvents.StageEntered.Count +
" Countdown " + WL.Combat.Stage.StageEvents.CountdownTick.Count +
" Started " + WL.Combat.Stage.StageEvents.StageStarted.Count +
" Enemy " + WL.Combat.Stage.StageEvents.EnemyCountChanged.Count +
" Time " + WL.Combat.Stage.StageEvents.TimeChanged.Count +
" Cleared " + WL.Combat.Stage.StageEvents.StageCleared.Count +
" Failed " + WL.Combat.Stage.StageEvents.StageFailed.Count +
" Exited " + WL.Combat.Stage.StageEvents.StageExited.Count);
var dlg = FindRevive(root.transform);
Chk(dlg != null, "813z ReviveDialog 컴포넌트 발견(IngameUIs/WL_HUD/WL_ReviveDialog)");
if (dlg != null) N("ReviveDialog init: " + dlg.Initialize());
Chk(ReviveDialog.Active != null, "ReviveDialog.Active 세팅");
}
static ReviveDialog FindRevive(Transform root)
{
var all = root.GetComponentsInChildren<ReviveDialog>(true);
return all != null && all.Length > 0 ? all[0] : null;
}
// ─────────────────────────────────────────────── ③ 상단 3요소
static void Step3_HudThreeItems()
{
H("③ 상단 3요소 — 스테이지 N · 남은 시간 mm:ss · 남은 적 M/T (+ 카운트다운)");
RunHud.ResetState();
StageUiBridge.ResetState();
WLStageUiProbeHooks.RaiseEntered(0, 1, 8, 60f, new Vector3(115f, 0.4f, -22f), 14f, 1.5f, 0);
Chk(StageUiBridge.EnteredSeen == 1, "StageEntered 수신 1");
Chk(RunHud.StageMode, "RunHud 스테이지 모드 on");
Chk(RunHud.PhaseText == "스테이지 1", "① 단계 = \"스테이지 1\" — 실측 \"" + RunHud.PhaseText + "\"");
Chk(RunHud.TimerText == "01:00", "② 타이머 = 01:00(제한 60 s) — 실측 \"" + RunHud.TimerText + "\"");
Chk(RunHud.EnemyText == "남은 적 8 / 8", "③ 남은 적 = \"남은 적 8 / 8\" — 실측 \"" + RunHud.EnemyText + "\"");
Chk(!RunHud.ZoneGaugeVisible, "존 게이지 접힘(기준서 §F-2) — 실측 활성=" + RunHud.ZoneGaugeVisible);
WLStageUiProbeHooks.RaiseCountdown(0, 1.5f, 1.5f);
Chk(RunHud.NoticeVisible && RunHud.NoticeText == "준비",
"카운트다운 = \"준비\" 표시 — 실측 보임=" + RunHud.NoticeVisible + " \"" + RunHud.NoticeText + "\"");
WLStageUiProbeHooks.RaiseStarted(0, 1, 8, 60f, 0);
Chk(!RunHud.NoticeVisible, "전투 시작 = 카운트다운 줄 접힘 — 실측 " + RunHud.NoticeVisible);
WLStageUiProbeHooks.RaiseEnemyCount(0, 5, 8);
Chk(RunHud.EnemyText == "남은 적 5 / 8", "처치 3 → \"남은 적 5 / 8\" — 실측 \"" + RunHud.EnemyText + "\"");
WLStageUiProbeHooks.RaiseTime(0, 42f, 60f);
Chk(RunHud.TimerText == "00:42" && !RunHud.TimerWarn, "42 s → 00:42 · 평상색 — 실측 \"" + RunHud.TimerText +
"\" warn=" + RunHud.TimerWarn);
WLStageUiProbeHooks.RaiseTime(0, 9.4f, 60f);
Chk(RunHud.TimerText == "00:10" && RunHud.TimerWarn,
"마지막 10 s → 붉은색(경고) — 실측 \"" + RunHud.TimerText + "\" warn=" + RunHud.TimerWarn +
" color=#" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor));
WLStageUiProbeHooks.RaiseEnemyCount(0, 2, 8);
Chk(RunHud.EnemyText == "남은 적 2 / 8", "남은 적 2 → 강조색 — 실측 \"" + RunHud.EnemyText + "\"");
// 815b 병행 발행(RunStarted/RunTick)이 스테이지 표시를 덮지 않는가
int ignored0 = RunHud.StageEventsIgnored;
WLRunUiProbeHooks.RaiseRunStarted(1, 60f, 1, "stage mirror");
WLRunUiProbeHooks.RaiseRunTick(18f, 42f, 0, 6, 8, 0, 1, WL.Combat.Run.RunPhase.Zones, 1);
Chk(RunHud.StageEventsIgnored - ignored0 == 2, "병행 RunEvents 2건 무시 — 실측 " + (RunHud.StageEventsIgnored - ignored0));
Chk(RunHud.PhaseText == "스테이지 1" && RunHud.EnemyText == "남은 적 2 / 8" && RunHud.TimerText == "00:10",
"🔴 병행 발행이 상단 3요소를 덮지 않는다 — \"" + RunHud.PhaseText + "\" \"" + RunHud.TimerText + "\" \"" + RunHud.EnemyText + "\"");
N("HUD 배치: " + RunHud.ApplyLayout());
N(RunHud.Dump());
}
// ─────────────────────────────────────────────── ④ 메뉴 숨김 / 복원
static void Step4_Menus()
{
H("④ 불필요 메뉴 숨김 · 복원 (§J ① · 원본 프리팹 diff 0)");
var g = WLStageUiSettings.Instance;
if (_root == null || g == null) { Chk(false, "프리팹 루트 없음"); return; }
var paths = g.stageHidePaths;
var found = new Transform[paths.Length];
var saved = new bool[paths.Length];
int missing = 0;
for (int i = 0; i < paths.Length; i++)
{
found[i] = WLVignetteUtil.FindUiPath(_root.transform, paths[i]);
if (found[i] == null) { missing++; continue; }
saved[i] = found[i].gameObject.activeSelf; // 프리팹 저장 상태
}
Chk(missing == 0, "숨김 목록 " + paths.Length + "개 전부 실존(경로 해석 성공) — 못 찾음 " + missing);
// 🔴 프리팹 **저장 상태**로는 인게임 화면을 재현할 수 없다(IngameUIs 는 저장 시 꺼져 있다).
// 실기에서 켜져 있는 상태를 만든 뒤 숨김 → 복원 왕복을 재는 것이 유일한 실측이다.
for (int i = 0; i < paths.Length; i++)
if (found[i] != null && !found[i].gameObject.activeSelf) found[i].gameObject.SetActive(true);
N("숨김: " + StageUiBridge.ApplyMenuHide(true));
var mid = new bool[paths.Length];
for (int i = 0; i < paths.Length; i++) mid[i] = found[i] != null && found[i].gameObject.activeSelf;
// 🔴 재보장 — 원본 `NewGameUI.SetUI_byGameMode` 가 스테이지 중에 같은 노드를 다시 켜는 경우
int reOn = 0;
for (int i = 0; i < paths.Length && reOn < 3; i++)
if (found[i] != null) { found[i].gameObject.SetActive(true); reOn++; }
int hit = StageUiBridge.ReassertHide();
int stillOn = 0;
for (int i = 0; i < paths.Length; i++) if (found[i] != null && found[i].gameObject.activeSelf) stillOn++;
Chk(hit == reOn && stillOn == 0, "원본이 다시 켠 " + reOn + "건을 재보장으로 도로 끔 — 실측 " + hit +
"건 · 남은 on " + stillOn + " (주기 " + g.menuReassertSeconds + "s)");
Chk(StageUiBridge.ReassertHide() == 0, "이미 꺼져 있으면 쓰기 0회(GC 0)");
N("복원: " + StageUiBridge.ApplyMenuHide(false));
int restored = 0, hidOk = 0;
_o.AppendLine(" | # | 경로(NewGameUI 루트 기준) | 실제 노드 경로 | 프리팹 저장 | 스테이지 중 | 이탈 뒤 |");
_o.AppendLine(" |---|---|---|---|---|---|");
for (int i = 0; i < paths.Length; i++)
{
bool after = found[i] != null && found[i].gameObject.activeSelf;
if (found[i] != null)
{
if (!mid[i]) hidOk++;
if (after) restored++;
}
_o.AppendLine(" | " + i + " | `" + paths[i] + "` | " + (found[i] != null ? "`" + FullPath(found[i], _root.transform) + "`" : "**없음**") +
" | " + (found[i] != null ? (saved[i] ? "on" : "off") : "-") +
" | " + (found[i] != null ? (mid[i] ? "on" : "**off**") : "-") +
" | " + (found[i] != null ? (after ? "**on**" : "off") : "-") + " |");
}
Chk(hidOk == paths.Length - missing, "스테이지 중 전부 SetActive(false) — " + hidOk + "/" + (paths.Length - missing));
Chk(restored == paths.Length - missing, "이탈 시 전부 켜진 값으로 복원 — " + restored + "/" + (paths.Length - missing));
// 프리팹 저장 상태로 되돌린다(미리보기 사본이지만 뒤 단계에 영향 0)
for (int i = 0; i < paths.Length; i++)
if (found[i] != null && found[i].gameObject.activeSelf != saved[i]) found[i].gameObject.SetActive(saved[i]);
// 유지 대상은 건드리지 않았는가
int keptOk = 0, keptMissing = 0;
for (int i = 0; i < g.stageKeepPaths.Length; i++)
{
var t = WLVignetteUtil.FindUiPath(_root.transform, g.stageKeepPaths[i]);
if (t == null) { keptMissing++; N(" (유지) " + g.stageKeepPaths[i] + " : 없음"); continue; }
keptOk++;
N(" (유지) " + g.stageKeepPaths[i] + " activeSelf=" + t.gameObject.activeSelf);
}
Chk(keptMissing == 0, "유지 목록 " + g.stageKeepPaths.Length + "개 전부 실존 — 없음 " + keptMissing);
N("유지 확인 " + keptOk + "개 (조이스틱·스킬 패드 BattleUI · HP/MP MyInfoUI · 813 WL HUD 요소)");
// 🔴 원본 프리팹 **파일**이 바뀌지 않았는가(LoadPrefabContents 는 메모리 사본이라 dirty 플래그는 뜬다 —
// 실제 판정은 디스크 파일의 길이·수정시각이다. 저장은 하지 않는다 = UnloadPrefabContents).
var fi = new FileInfo(Path.Combine(Root, kPrefab.Replace('/', Path.DirectorySeparatorChar)));
Chk(fi.Length == _prefabLen && fi.LastWriteTimeUtc == _prefabTime,
"원본 NewGameUI.prefab 파일 무변경 — 길이 " + fi.Length + "B(=" + _prefabLen + ") 수정시각 " +
fi.LastWriteTimeUtc.ToString("HH:mm:ss") + "(=" + _prefabTime.ToString("HH:mm:ss") + ")");
N("(참고) 미리보기 사본 dirty=" + EditorUtility.IsDirty(_root) +
" — 런타임 노드는 HideFlags.DontSave 라 저장해도 굽히지 않는다(813y 규약)");
}
// ─────────────────────────────────────────────── ⑤ 클리어 화면
static void Step5_Clear()
{
H("⑤ 클리어 화면 — StageCleared → 813q RunResultUI (기준서 §F-1)");
var g = WLStageUiSettings.Instance;
RunResultUI.ResetState();
StageUiBridge.ResetState();
StageUiBridge.ProbeSetInStage(true);
// 피격 3회 · 연쇄 5 를 스테이지 구간 동안 실측
WLStageUiProbeHooks.RaiseDamaged(null); // victim 이 PC 가 아니면 세지 않는다
Chk(StageUiBridge.Hits == 0, "Damaged(victim=null)는 피격으로 세지 않는다 — 실측 " + StageUiBridge.Hits);
StageUiBridge.ProbeNoteHit(); StageUiBridge.ProbeNoteHit(); StageUiBridge.ProbeNoteHit();
WLStageUiProbeHooks.RaiseChainEnded(5, 2, 1.2f);
WLStageUiProbeHooks.RaiseChainEnded(3, 1, 0.8f);
Chk(StageUiBridge.Hits == 3, "피격 3회 집계 — 실측 " + StageUiBridge.Hits);
Chk(StageUiBridge.MaxChain == 5, "최대 연쇄 = 5(3 은 갱신 안 함) — 실측 " + StageUiBridge.MaxChain);
WLStageUiProbeHooks.RaiseCleared(0, 1, 34.2f, 60f, 8, 8, 1, false, false);
Chk(StageUiBridge.ClearedSeen == 1, "StageCleared 수신 1");
Chk(RunResultUI.IsOpen, "결과 화면 표시 — 실측 open=" + RunResultUI.IsOpen);
Chk(RunResultUI.StageMode, "결과창 스테이지 모드 on");
Chk(RunResultUI.TitleText == "스테이지 1 클리어!", "제목 = \"스테이지 1 클리어!\" — 실측 \"" + RunResultUI.TitleText + "\"");
N("본문 =\n" + Indent(RunResultUI.BodyText));
Chk(RunResultUI.VisibleRows == g.stageResultRows.Length,
"행 " + g.stageResultRows.Length + "개 — 실측 " + RunResultUI.VisibleRows);
Chk(!RunResultUI.BadgeVisible, "피격 3회 → 무피격 배지 없음 — 실측 " + RunResultUI.BadgeVisible);
N("배치: " + RunResultUI.ApplyLayout());
// 병행 RunEnded(Win) 이 결과창을 두 번 띄우지 않는가
int shows0 = RunResultUI.ShownCount;
var mirror = new WL.Combat.Run.RunResult { outcome = WL.Combat.Run.RunOutcome.Win, runIndex = 1, totalSec = 34.2f, kills = 8 };
WLRunUiProbeHooks.RaiseRunEnded(in mirror);
Chk(RunResultUI.ShownCount == shows0, "🔴 병행 RunEnded(Win) 로 결과창 중복 표시 0 — 실측 +" +
(RunResultUI.ShownCount - shows0) + " · 억제 " + RunResultUI.SuppressedRunEndedCount + "회");
Chk(RunResultUI.TitleText == "스테이지 1 클리어!", "제목이 「런 클리어!」로 덮이지 않았다 — \"" + RunResultUI.TitleText + "\"");
// 무피격 클리어 → 배지
StageUiBridge.ResetState();
StageUiBridge.ProbeSetInStage(true);
WLStageUiProbeHooks.RaiseCleared(1, 2, 21.5f, 60f, 10, 10, 0, false, false);
Chk(RunResultUI.BadgeVisible && RunResultUI.BadgeText == "무피격 클리어!",
"피격 0회 → 무피격 배지 — 실측 " + RunResultUI.BadgeVisible + " \"" + RunResultUI.BadgeText + "\"");
// 마지막 스테이지 → [처음부터]
WLStageUiProbeHooks.RaiseCleared(9, 10, 55.0f, 60f, 15, 15, 2, true, true);
Chk(RunResultUI.TitleText == "전 스테이지 클리어!", "마지막 스테이지 제목 — 실측 \"" + RunResultUI.TitleText + "\"");
N("마지막 스테이지 배치: " + RunResultUI.ApplyLayout());
N("본문(보스 스테이지) =\n" + Indent(RunResultUI.BodyText));
}
// ─────────────────────────────────────────────── ⑥ 실패 팝업
static void Step6_Fail()
{
H("⑥ 실패 팝업 — StageFailed → 813z ReviveDialog 2버튼 (기준서 §E)");
var dlg = ReviveDialog.Active;
if (dlg == null) { Chk(false, "ReviveDialog.Active 없음"); return; }
RunResultUI.ResetState();
StageUiBridge.ResetState();
StageUiBridge.ProbeSetInStage(true);
WLStageUiProbeHooks.RaiseFailed(0, 1, WL.Combat.Stage.StageFailReason.TimeLimit, 3, 8, 60f, 0);
Chk(StageUiBridge.FailedSeen == 1, "StageFailed 수신 1");
Chk(dlg.IsOpen, "실패 팝업 표시 — 실측 open=" + dlg.IsOpen);
Chk(ReviveDialog.StageMode, "ReviveDialog 스테이지 모드 on");
Chk(!dlg.UsedCommonPopup, "공용 Popup 미사용(2버튼 불가) — 실측 " + dlg.UsedCommonPopup);
N("배치: " + dlg.ApplyLayout());
Chk(dlg.SecondaryVisible, "두 번째 버튼 노출 — 실측 " + dlg.SecondaryVisible);
Chk(dlg.PrimaryButtonText == "다시 도전" && dlg.SecondaryButtonText == "로비로",
"버튼 = [다시 도전] [로비로] — 실측 [" + dlg.PrimaryButtonText + "] [" + dlg.SecondaryButtonText + "]");
Chk(dlg.LastMessage.Contains("시간 초과") && dlg.LastMessage.Contains("3"),
"시간 초과 문구 + 남은 적 3 — 실측 \"" + dlg.LastMessage.Replace("\n", " / ") + "\"");
// 🔴 813z 3층 방어 — 전용 Canvas 는 붙었지만 **부모 계층이 비활성인 동안 Unity 가 overrideSorting 을 되돌린다**
// (814d §4 실측). 그래서 에디트 모드의 SortingOrder 게터는 1 이다. SO 값과 재보장 횟수로 확인한다.
var sv2 = WLSurvivalSettings.Instance;
Chk(sv2 != null && sv2.reviveSortingOrder == 60 && dlg.TopmostCount >= 1,
"813z 3층 방어 유지 — SO sortingOrder " + (sv2 != null ? sv2.reviveSortingOrder : -1) +
" · 최상단 재보장 " + dlg.TopmostCount + "회 · 실측 게터=" + dlg.SortingOrder +
"(부모 계층 비활성 → Unity 가 overrideSorting 되돌림 · 814d §4)");
Chk(RunResultUI.SuppressedRunEndedCount == 0 && !RunResultUI.IsOpen, "실패 시점에 결과창 0");
// 병행 RunEnded(Timeout) 억제
var mirror = new WL.Combat.Run.RunResult { outcome = WL.Combat.Run.RunOutcome.Timeout, runIndex = 1, totalSec = 60f, kills = 5 };
WLRunUiProbeHooks.RaiseRunEnded(in mirror);
Chk(!RunResultUI.IsOpen && RunResultUI.SuppressedRunEndedCount == 1,
"🔴 병행 RunEnded(Timeout) 억제 → 결과창 안 뜸 — 실측 open=" + RunResultUI.IsOpen +
" 억제 " + RunResultUI.SuppressedRunEndedCount + "회");
// 버튼 → StageDirector
// 🔴 순서 주의: 실기 순서는 「입장 → 실패 → 클릭」이다. 프로브도 같은 순서로 태운다
// (입장(StageEntered)이 실패 팝업 모드를 지우기 때문 — 이것이 정상 동작이다).
WL.Combat.Stage.StageDirector.ResetAll();
try { WL.Combat.Stage.StageDirector.Enter(0, "815d fail probe"); } catch (Exception e) { N("Enter 예외 " + e.GetType().Name); }
StageUiBridge.ProbeSetInStage(true);
// 사망 문구
WLStageUiProbeHooks.RaiseFailed(0, 1, WL.Combat.Stage.StageFailReason.PlayerDied, 4, 8, 22f, 1);
Chk(dlg.LastMessage.Contains("쓰러졌"), "사망 문구 구분 — 실측 \"" + dlg.LastMessage.Replace("\n", " / ") + "\"");
Chk(ReviveDialog.StageMode, "사망 실패도 2버튼 모드 — 실측 " + ReviveDialog.StageMode);
int retry0 = WL.Combat.Stage.StageDirector.RetryCount;
N("클릭 [다시 도전] : " + dlg.ProbeClickPrimary());
Chk(WL.Combat.Stage.StageDirector.RetryCount == retry0 + 1,
"[다시 도전] → StageDirector.Retry() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.RetryCount - retry0));
Chk(!dlg.IsOpen, "누르면 팝업 닫힘 — 실측 open=" + dlg.IsOpen);
WLStageUiProbeHooks.RaiseFailed(0, 1, WL.Combat.Stage.StageFailReason.PlayerDied, 4, 8, 22f, 2);
int lobby0 = WL.Combat.Stage.StageDirector.LobbyCount;
N("클릭 [로비로] : " + dlg.ProbeClickSecondary());
Chk(WL.Combat.Stage.StageDirector.LobbyCount == lobby0 + 1,
"[로비로] → StageDirector.ToLobby() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.LobbyCount - lobby0));
N(dlg.Dump());
}
// ─────────────────────────────────────────────── ⑦ 결과창 버튼
static void Step7_Counters()
{
H("⑦ 클리어 화면 버튼 → StageDirector.Next() / ToLobby()");
StageUiBridge.ResetState();
StageUiBridge.ProbeSetInStage(true);
WL.Combat.Stage.StageDirector.ResetAll();
try { WL.Combat.Stage.StageDirector.Enter(0, "815d btn probe"); } catch (Exception e) { N("Enter 예외 " + e.GetType().Name); }
WLStageUiProbeHooks.RaiseCleared(0, 1, 30f, 60f, 8, 8, 0, false, false);
int next0 = WL.Combat.Stage.StageDirector.NextCount;
N("클릭 [다음 스테이지] : " + RunResultUI.OnNextRun());
Chk(WL.Combat.Stage.StageDirector.NextCount == next0 + 1,
"[다음 스테이지] → StageDirector.Next() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.NextCount - next0));
Chk(!RunResultUI.IsOpen, "누르면 결과창 닫힘");
WLStageUiProbeHooks.RaiseCleared(0, 1, 30f, 60f, 8, 8, 0, false, false);
int lobby0 = WL.Combat.Stage.StageDirector.LobbyCount;
N("클릭 [로비로] : " + RunResultUI.OnClose());
Chk(WL.Combat.Stage.StageDirector.LobbyCount == lobby0 + 1,
"[로비로] → StageDirector.ToLobby() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.LobbyCount - lobby0));
N("로비 동작: " + StageUiBridge.LastLog);
// 마지막 스테이지 → [처음부터] = Enter(0)
WLStageUiProbeHooks.RaiseCleared(9, 10, 50f, 60f, 15, 15, 0, true, true);
int enter0 = WL.Combat.Stage.StageDirector.EnterCount;
N("클릭 [처음부터] : " + RunResultUI.OnNextRun());
Chk(WL.Combat.Stage.StageDirector.EnterCount == enter0 + 1,
"[처음부터] → StageDirector.Enter(0) 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.EnterCount - enter0));
// 이탈 → 스테이지 모드 해제 + 메뉴 복원
WLStageUiProbeHooks.RaiseExited(0, true, "probe lobby");
Chk(!RunHud.StageMode, "이탈 → RunHud 스테이지 모드 off");
Chk(!RunResultUI.StageMode, "이탈 → 결과창 스테이지 모드 off");
Chk(!ReviveDialog.StageMode, "이탈 → 실패 팝업 모드 off(813z 원래 1버튼)");
N(StageUiBridge.Dump());
}
// ─────────────────────────────────────────────── ⑧ GC
static void Step8_Gc()
{
H("⑧ GC — 같은 값 100틱 = 표시 쓰기 0회");
StageUiBridge.ResetState();
WLStageUiProbeHooks.RaiseEntered(0, 3, 12, 60f, Vector3.zero, 14f, 1.5f, 0);
WLStageUiProbeHooks.RaiseStarted(0, 3, 12, 60f, 0);
WLStageUiProbeHooks.RaiseTime(0, 30f, 60f);
WLStageUiProbeHooks.RaiseEnemyCount(0, 7, 12);
int t0 = RunHud.TimerWrites, e0 = RunHud.EnemyWrites, p0 = RunHud.StageLabelWrites;
long m0 = GC.GetTotalMemory(false);
for (int i = 0; i < 100; i++)
{
WLStageUiProbeHooks.RaiseTime(0, 30f, 60f);
WLStageUiProbeHooks.RaiseEnemyCount(0, 7, 12);
}
long m1 = GC.GetTotalMemory(false);
Chk(RunHud.TimerWrites == t0, "같은 초 100틱 → 타이머 쓰기 0회 — 실측 +" + (RunHud.TimerWrites - t0));
Chk(RunHud.EnemyWrites == e0, "같은 수 100틱 → 남은 적 쓰기 0회 — 실측 +" + (RunHud.EnemyWrites - e0));
Chk(RunHud.StageLabelWrites == p0, "같은 스테이지 → 라벨 쓰기 0회 — 실측 +" + (RunHud.StageLabelWrites - p0));
N("ΔGetTotalMemory = " + (m1 - m0) + " B (에디터 TMP 문자열 포함 · 빌드에는 없는 줄)");
WLStageUiProbeHooks.RaiseExited(0, false, "gc probe end");
}
// ─────────────────────────────────────────────── ⑨ C8
static void Step9_C8()
{
H("⑨ C8 — WLStageUiSettings 를 끄면 813q/813z 가 원래대로");
StageUiBridge.ResetState();
RunHud.ResetState();
RunResultUI.ResetState();
WLStageUiSettings.RuntimeDisabled = true;
Chk(!WLStageUiSettings.Enabled, "RuntimeDisabled → Enabled=false");
WLStageUiProbeHooks.RaiseEntered(0, 4, 15, 60f, Vector3.zero, 14f, 1.5f, 0);
Chk(!RunHud.StageMode, "축 off → 스테이지 모드 0 — 실측 " + RunHud.StageMode);
Chk(StageUiBridge.HiddenCount == 0, "축 off → 메뉴 숨김 0 — 실측 " + StageUiBridge.HiddenCount);
WLStageUiProbeHooks.RaiseCleared(0, 4, 12f, 60f, 15, 15, 0, false, false);
Chk(!RunResultUI.StageMode, "축 off → 결과창 스테이지 모드 0");
WLStageUiProbeHooks.RaiseFailed(0, 4, WL.Combat.Stage.StageFailReason.TimeLimit, 5, 15, 60f, 0);
Chk(!ReviveDialog.StageMode, "축 off → 실패 팝업 2버튼 0(813z 원래 부활 팝업)");
// 813q 런 축은 그대로 돈다
Chk(WLRunUiSettings.Enabled, "813q WLRunUiSettings.Enabled 불변 = " + WLRunUiSettings.Enabled);
WLRunUiProbeHooks.RaiseRunStarted(2, 240f, 4, "c8 probe");
Chk(RunHud.TimerText == "04:00" && RunHud.PhaseText == "존 1",
"813q 런 HUD 원래대로 — 실측 \"" + RunHud.TimerText + "\" \"" + RunHud.PhaseText + "\"");
WLStageUiSettings.RuntimeDisabled = false;
Chk(WLStageUiSettings.Enabled, "복구 → Enabled=true");
}
/// <summary>root 기준 전체 경로(실제로 어느 노드를 잡았는지 실측 표기).</summary>
static string FullPath(Transform t, Transform root)
{
var sb = new StringBuilder(t.name);
var p = t.parent;
while (p != null && p != root) { sb.Insert(0, p.name + "/"); p = p.parent; }
return sb.ToString();
}
static string Indent(string s)
{
if (string.IsNullOrEmpty(s)) return " (없음)";
return " " + s.TrimEnd('\n').Replace("\n", "\n ");
}
static void Cleanup()
{
try
{
StageUiBridge.Teardown();
RunHud.Teardown();
RunResultUI.Teardown();
WL.Combat.Stage.StageDirector.ResetAll();
WL.Combat.Stage.StageBarrier.Clear();
}
catch (Exception e) { _o.AppendLine(" (정리 예외 " + e.GetType().Name + ")"); }
if (_root != null) { PrefabUtility.UnloadPrefabContents(_root); _root = null; }
}
// ═════════════════════════════════════════════════════════════════════
// 캡처 3장 — 1080×1920 목업(실 노드 · 실 값 · RenderTexture 렌더)
// ═════════════════════════════════════════════════════════════════════
public static string Capture()
{
var sb = new StringBuilder();
string dir = Path.Combine(Path.Combine(Root, "Screenshots_WL"), "WL815d");
Directory.CreateDirectory(dir);
GameObject rig = null, prefabRoot = null;
try
{
prefabRoot = PrefabUtility.LoadPrefabContents(kPrefab);
var revive = FindRevive(prefabRoot.transform);
rig = new GameObject("WL815d_CaptureRig");
rig.hideFlags = HideFlags.HideAndDontSave;
var camGo = new GameObject("Cam");
camGo.transform.SetParent(rig.transform, false);
var cam = camGo.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.09f, 0.10f, 0.12f, 1f);
cam.orthographic = true;
cam.orthographicSize = 960f;
cam.nearClipPlane = 0.1f; cam.farClipPlane = 5000f;
camGo.transform.position = new Vector3(0f, 0f, -1000f);
var canvasGo = new GameObject("Canvas", typeof(RectTransform));
canvasGo.transform.SetParent(rig.transform, false);
canvasGo.layer = 5;
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = cam;
canvas.planeDistance = 500f;
var scaler = canvasGo.AddComponent<UnityEngine.UI.CanvasScaler>();
scaler.uiScaleMode = UnityEngine.UI.CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080f, 1920f);
scaler.matchWidthOrHeight = 0.5f;
canvasGo.AddComponent<UnityEngine.UI.GraphicRaycaster>();
var crt = (RectTransform)canvasGo.transform;
crt.sizeDelta = new Vector2(1080f, 1920f);
// 813q/813z 가 찾는 부모 경로(IngameUIs/WL_HUD)를 목업 캔버스 안에 그대로 만든다
var ingame = NewRect(crt, "IngameUIs");
var wlHud = NewRect(ingame, "WL_HUD");
// 폰트는 원본 UI 에서 빌린다(한글 글리프)
var font = RunHud.BorrowFont(prefabRoot.transform);
RunHud.SetFont(font); RunResultUI.SetFont(font);
StageUiBridge.SetUiRootForProbe(prefabRoot.transform);
RunHud.Subscribe(true); RunResultUI.Subscribe(true); StageUiBridge.Subscribe(true);
sb.AppendLine("HUD : " + RunHud.Bind(crt));
sb.AppendLine("결과 : " + RunResultUI.Bind(crt));
// 실패 팝업은 목업 캔버스 안에 새 인스턴스로 만든다(원본 프리팹 무수정)
var dlgGo = new GameObject("WL_ReviveDialog", typeof(RectTransform));
dlgGo.layer = 5;
dlgGo.transform.SetParent(wlHud, false);
revive = dlgGo.AddComponent<ReviveDialog>();
revive.SetFont(font);
sb.AppendLine("팝업 : " + revive.Initialize());
var rt = new RenderTexture(1080, 1920, 24, RenderTextureFormat.ARGB32);
cam.targetTexture = rt;
// ① HUD (스테이지 3요소)
WLStageUiProbeHooks.RaiseEntered(0, 3, 12, 60f, new Vector3(147f, 2.6f, 30f), 14f, 1.5f, 0);
WLStageUiProbeHooks.RaiseStarted(0, 3, 12, 60f, 0);
WLStageUiProbeHooks.RaiseEnemyCount(0, 5, 12);
WLStageUiProbeHooks.RaiseTime(0, 8f, 60f);
RunHud.ApplyLayout();
RunResultUI.Hide();
if (revive != null) revive.HideNow();
sb.AppendLine("① HUD \"" + RunHud.PhaseText + "\" \"" + RunHud.TimerText + "\" \"" + RunHud.EnemyText + "\"");
sb.AppendLine(Shot(cam, rt, Path.Combine(dir, "01_hud_stage.png")));
// ② 클리어 화면
StageUiBridge.ProbeSetInStage(true);
StageUiBridge.ProbeNoteHit();
WLStageUiProbeHooks.RaiseChainEnded(6, 2, 1.4f);
WLStageUiProbeHooks.RaiseCleared(0, 3, 34.2f, 60f, 12, 12, 1, false, false);
RunResultUI.ApplyLayout();
sb.AppendLine("② 클리어 \"" + RunResultUI.TitleText + "\" 행=" + RunResultUI.VisibleRows +
" 배지=" + RunResultUI.BadgeVisible);
sb.AppendLine(Shot(cam, rt, Path.Combine(dir, "02_clear.png")));
// ③ 실패 팝업
RunResultUI.Hide();
StageUiBridge.ProbeSetInStage(true);
WLStageUiProbeHooks.RaiseFailed(0, 3, WL.Combat.Stage.StageFailReason.TimeLimit, 4, 12, 60f, 0);
if (revive != null) revive.ApplyLayout();
sb.AppendLine("③ 실패 \"" + (revive != null ? revive.LastMessage.Replace("\n", " / ") : "-") + "\" 버튼=[" +
(revive != null ? revive.PrimaryButtonText : "") + "] [" +
(revive != null ? revive.SecondaryButtonText : "") + "]");
sb.AppendLine(Shot(cam, rt, Path.Combine(dir, "03_fail.png")));
cam.targetTexture = null;
UnityEngine.Object.DestroyImmediate(rt);
}
catch (Exception e) { sb.AppendLine("예외 " + e.GetType().Name + " : " + e.Message + "\n" + e.StackTrace); }
finally
{
try { StageUiBridge.Teardown(); RunHud.Teardown(); RunResultUI.Teardown(); } catch { }
if (rig != null) UnityEngine.Object.DestroyImmediate(rig);
if (prefabRoot != null) PrefabUtility.UnloadPrefabContents(prefabRoot);
}
return sb.ToString();
}
static RectTransform NewRect(RectTransform parent, string name)
{
var go = new GameObject(name, typeof(RectTransform));
go.layer = 5;
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
rt.localScale = Vector3.one;
return rt;
}
static string Shot(Camera cam, RenderTexture rt, string path)
{
Canvas.ForceUpdateCanvases();
cam.Render();
var prev = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false);
tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
tex.Apply();
RenderTexture.active = prev;
var png = tex.EncodeToPNG();
UnityEngine.Object.DestroyImmediate(tex);
File.WriteAllBytes(path, png);
return " → " + path + " (" + png.Length + " B · " + rt.width + "×" + rt.height + ")";
}
}

View File

@ -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

View File

@ -37,7 +37,7 @@ MonoBehaviour:
carveHeight: 3
carveOverlap: 0.15
carveStaticOnly: 1
style: 1
style: 2
showVisual: 1
appearSeconds: 0.6
fadeSeconds: 0.6

View File

@ -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
{
/// <summary>815b 장벽 훅에 815c 구현을 대입한다(정적 · 오브젝트 0 · 값 0).</summary>
public static class StageBarrierBinding
{
/// <summary>지금 훅이 815c 구현을 가리키고 있는가(프로브가 읽는다).</summary>
public static bool Bound { get; private set; }
/// <summary>대입 횟수(진단).</summary>
public static int BindCount, UnbindCount;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Boot() { Bind(); }
/// <summary>훅 2줄. 몇 번 불러도 결과가 같다(멱등).</summary>
public static string Bind()
{
StageBarrierHook.Set = StageBarrier.Set;
StageBarrierHook.Clear = StageBarrier.Clear;
Bound = true;
BindCount++;
return "장벽 훅 연결 — Set/Clear = WL.Combat.Stage.StageBarrier (bind " + BindCount + "회)";
}
/// <summary>훅을 떼어 낸다(C8 ① · 프로브 A/B). 뗀 뒤에는 815b 가 값만 기록하고 아무 일도 하지 않는다.</summary>
public static string Unbind()
{
StageBarrierHook.Set = null;
StageBarrierHook.Clear = null;
Bound = false;
UnbindCount++;
return "장벽 훅 해제 — no-op 복귀(unbind " + UnbindCount + "회)";
}
/// <summary>연결 상태 덤프(실측용).</summary>
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d815d01a1b2c3d4e5f60708090a0c03

View File

@ -53,6 +53,20 @@ namespace WL.UI
/// <summary>팝업이 뜰 때 불린다(진단·튜토리얼용 · 선택).</summary>
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 실측과 같은 이유).
/// <summary>[다시 도전] — 대입되어 있으면 [확인] 대신 이것이 불린다(815d `StageUiBridge`).</summary>
public static System.Action PrimaryOverride;
/// <summary>[로비로] — 대입되어 있으면 두 번째 버튼이 생긴다.</summary>
public static System.Action SecondaryAction;
/// <summary>지금 스테이지 실패 팝업 모드인가(문구·버튼 2개).</summary>
public static bool StageMode { get; private set; }
public static int StagePrimaryCount, StageSecondaryCount, StageShownCount;
/// <summary>현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다).</summary>
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) 최상단 보장 ──────────────────────────────────────────
/// <summary>런타임 생성 확인 버튼 이름 — 원본 `NightmareResult/btn_ok` 와 겹치지 않게 접두어를 붙인다(D-9).</summary>
public const string OkButtonName = "WL_btn_ok";
/// <summary>WL-815d — 두 번째 버튼 이름(같은 D-9 규약으로 `WL_` 접두).</summary>
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
/// <summary>본문 = 설정 문자열(로컬라이즈되면 reviveMessageKey 우선) + 비용 줄.</summary>
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) — 승격 직후에도 최상단·레이캐스트 보장
}
/// <summary>확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다.</summary>
/// <summary>확인 = 부활 요청. 813i 가 ReviveRequested 를 채우면 실제 부활이 일어난다.
/// 🔴 WL-815d: 스테이지 실패 팝업이면 [다시 도전](`StageDirector.Retry()`)이 대신 불린다.</summary>
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 "확인 — 부활 요청 전달";
}
/// <summary>WL-815d — 두 번째 버튼([로비로]).</summary>
public string OnSecondary()
{
HideNow();
if (SecondaryAction == null) return "두 번째 버튼 — 핸들러 미연결(무동작)";
StageSecondaryCount++;
try { SecondaryAction(); } catch { }
return "로비로 — 전달";
}
// ── WL-815d 스테이지 실패 팝업 진입점 ────────────────────────────────
private string _msgOverride, _okOverride, _secondOverride;
/// <summary>
/// 스테이지 실패 팝업을 **지금** 연다(2버튼 · 전용 폴백 패널 고정 · 공용 Popup 미사용).
/// 사망 경로(DeathFlow → NotifyReviveRequested)로 이미 떠 있으면 문구·버튼만 갈아 끼운다.
/// </summary>
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;
}
/// <summary>스테이지 실패 팝업 모드를 끈다(813z 원래 1버튼 부활 팝업으로 복귀).</summary>
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<Image>();
if (img == null) img = t.gameObject.AddComponent<Image>();
img.raycastTarget = true;
secondaryButton = t.GetComponent<Button>();
if (secondaryButton == null) secondaryButton = t.gameObject.AddComponent<Button>();
secondaryButton.targetGraphic = img;
secondaryButton.onClick.RemoveListener(OnSecondListener);
secondaryButton.onClick.AddListener(OnSecondListener);
t.gameObject.SetActive(false);
made = true;
}
if (secondaryLabel == null)
{
var t = WLVignetteUtil.NewChild(secondaryButton.GetComponent<RectTransform>(), "Label");
secondaryLabel = t.GetComponent<TextMeshProUGUI>();
if (secondaryLabel == null) secondaryLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
secondaryLabel.raycastTarget = false;
secondaryLabel.alignment = TextAlignmentOptions.Center;
if (font != null) secondaryLabel.font = font;
made = true;
}
// WL-813z(D-9) — 이전 세션이 만든(또는 직렬화된) `btn_ok` 도 이름을 통일한다.
if (okButton != null && okButton.gameObject.name != OkButtonName) okButton.gameObject.name = OkButtonName;
if (secondaryButton != null && secondaryButton.gameObject.name != SecondaryButtonName) secondaryButton.gameObject.name = SecondaryButtonName;
return made;
}
private void OnOkListener() { OnConfirm(); }
private void OnSecondListener() { OnSecondary(); }
public string ApplyLayout()
{
@ -453,25 +561,52 @@ namespace WL.UI
messageLabel.color = Color.white;
if (font != null && messageLabel.font != font) messageLabel.font = font;
}
// WL-815d — 버튼 1개(813z 원래) / 2개(스테이지 실패)를 같은 산식으로 앉힌다.
bool two = StageMode && SecondaryAction != null;
float btnW = two ? 300f : 320f;
float halfGap = two ? (btnW + 24f) * 0.5f : 0f;
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);
t.anchoredPosition = new Vector2(-halfGap * u, 28f * u);
t.sizeDelta = new Vector2(btnW * 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.text = two && !string.IsNullOrEmpty(_okOverride) ? _okOverride : 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");
if (secondaryButton != null)
{
if (secondaryButton.gameObject.activeSelf != two) secondaryButton.gameObject.SetActive(two);
var t = secondaryButton.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(halfGap * u, 28f * u);
t.sizeDelta = new Vector2(btnW * u, 96f * u);
var img = secondaryButton.targetGraphic as Image;
if (img != null) img.color = new Color(0.16f, 0.18f, 0.22f, 1f);
}
if (secondaryLabel != null)
{
WLVignetteUtil.Stretch(secondaryLabel.rectTransform);
secondaryLabel.text = string.IsNullOrEmpty(_secondOverride) ? "" : _secondOverride;
secondaryLabel.fontSize = s.reviveFallbackFontPx * u * 0.8f;
secondaryLabel.color = new Color(0.90f, 0.92f, 0.95f, 1f);
if (font != null && secondaryLabel.font != font) secondaryLabel.font = font;
}
return "폴백 팝업 box size" + box.sizeDelta + " 버튼=" + (two ? 2 : 1) +
" [" + (okLabel != null ? okLabel.text : "") + "]" +
(two ? " [" + (secondaryLabel != null ? secondaryLabel.text : "") + "]" : "") +
" unitsPerPx=" + u.ToString("F4");
}
public float UnitsPerPx()
@ -486,8 +621,17 @@ namespace WL.UI
if (f == null) return;
if (messageLabel != null) messageLabel.font = f;
if (okLabel != null) okLabel.font = f;
if (secondaryLabel != null) secondaryLabel.font = f; // WL-815d
}
/// <summary>WL-815d — 지금 화면의 버튼 문구(실측 덤프용).</summary>
public string PrimaryButtonText { get { return okLabel != null ? okLabel.text : ""; } }
public string SecondaryButtonText { get { return secondaryLabel != null ? secondaryLabel.text : ""; } }
public bool SecondaryVisible { get { return secondaryButton != null && secondaryButton.gameObject.activeSelf; } }
/// <summary>프로브 전용 — 버튼 클릭 경로를 그대로 태운다(리스너와 같은 함수).</summary>
public string ProbeClickPrimary() { return OnConfirm(); }
public string ProbeClickSecondary() { return OnSecondary(); }
// ── 검증 전용 ────────────────────────────────────────────────────────
/// <summary>
/// 프로브 전용(WL-813z) — 에디트 모드에는 공용 `Popup` 싱글턴이 없어 그 경로를 탈 수 없다.
@ -523,6 +667,11 @@ namespace WL.UI
" 813i(ReviveRequested)=" + (ReviveRequested != null) +
" 브리지=" + WLSurvivalUiBridge.Installed);
sb.AppendLine(" 본문=\"" + LastMessage.Replace("\n", " / ") + "\"");
sb.AppendLine(" [815d] 스테이지모드=" + StageMode + " 버튼=[" + PrimaryButtonText + "]" +
(SecondaryVisible ? " [" + SecondaryButtonText + "]" : " (두번째 없음)") +
" · 핸들러 primary=" + (PrimaryOverride != null) + " secondary=" + (SecondaryAction != null) +
" · 클릭 다시도전=" + StagePrimaryCount + " 로비=" + StageSecondaryCount +
" · 표시 " + StageShownCount + "회");
sb.AppendLine(" 공용 Popup.Ins=" + (Popup.Ins != null ? "있음" : "없음(에디트 모드/씬 미로드)"));
// WL-813z(D-3/D-9)
var g = WLSurvivalSettings.Instance;

View File

@ -67,6 +67,21 @@ namespace WL.UI
private static float s_remaining;
private static bool s_bossPhase;
// ── WL-815d 스테이지 모드 ─────────────────────────────────────────────
// PD 추가 지시(#815 · 00:2x): 「화면 상단에는 스테이지 정보와 남은 시간, 그리고 처치할 적의
// 마릿수 정보만을 제공해야 함」 → 이 모드에서는 존 게이지를 숨기고 단계 라벨을 "스테이지 N" 으로,
// 그 아래에 "남은 적 M / T" 줄을 하나 더 둔다. 813p RunEvents 핸들러는 전부 **무시**한다
// (815b `mirrorRunHudEvents` 가 RunStarted/RunTick 을 병행 발행하기 때문 — 덮어쓰기 방지).
private static bool s_stageMode;
private static TextMeshProUGUI s_enemy; // "남은 적 M / T"
private static TextMeshProUGUI s_notice; // 카운트다운("준비") 등 한 줄 알림
private static int s_shownStageNo = int.MinValue;
private static int s_shownEnemyRemain = int.MinValue, s_shownEnemyTotal = int.MinValue;
private static bool s_shownEnemyHi;
private static string s_shownNotice = null;
public static int StageLabelWrites, EnemyWrites, NoticeWrites;
public static int StageEventsIgnored; // 스테이지 모드에서 무시한 RunEvents 수(실측)
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
public static int StartedSeen, TickSeen, ZoneClearedSeen, GateSeen, BossSeen, EndedSeen;
public static int TimerWrites, PhaseWrites, FillWrites;
@ -126,6 +141,7 @@ namespace WL.UI
private static void OnRunStarted(in RunStartedEvent e)
{
StartedSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d — 스테이지 병행 발행은 무시
s_runSeconds = e.runSeconds;
s_zoneCount = e.zoneCount;
s_remaining = e.runSeconds;
@ -146,6 +162,7 @@ namespace WL.UI
private static void OnRunTick(in RunTickEvent e)
{
TickSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d
s_remaining = e.remainingSec;
if (e.zoneCount != s_zoneCount) { s_zoneCount = e.zoneCount; BuildZoneCells(e.zoneCount); }
WriteTimer(e.remainingSec);
@ -163,6 +180,7 @@ namespace WL.UI
private static void OnZoneCleared(in ZoneClearedEvent e)
{
ZoneClearedSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d — 813o 카드 트리거용 병행 발행
if (e.zoneIndex >= 0 && e.zoneIndex < s_zoneCells)
{
SetFill(e.zoneIndex, 1f, true);
@ -175,6 +193,7 @@ namespace WL.UI
private static void OnBossGateOpened(in BossGateOpenedEvent e)
{
GateSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d
s_bossPhase = true;
WritePhase(true, 0);
}
@ -182,6 +201,7 @@ namespace WL.UI
private static void OnBossStarted(in BossStartedEvent e)
{
BossSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d
s_bossPhase = true; // e.boss 는 null 일 수 있다(813p 계약) — 여기서는 쓰지 않는다
WritePhase(true, 0);
}
@ -189,6 +209,7 @@ namespace WL.UI
private static void OnRunEnded(in RunResult e)
{
EndedSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d — 표시 종료는 StageEvents 가 쥔다
s_bossPhase = false;
var s = St;
if (s != null && s.runHudHideOnEnd) SetVisible(false);
@ -213,24 +234,46 @@ namespace WL.UI
return 5;
}
/// <summary>WL-815d — 스테이지 모드면 경고 초/색을 `WLStageUiSettings`(60 s 규칙 = 마지막 10 s)에서 가져온다.</summary>
private static float WarnSeconds(WLRunUiSettings s)
{
if (s_stageMode)
{
var g = WLStageUiSettings.Instance;
if (g != null && WLStageUiSettings.Enabled) return g.timerWarnSeconds;
}
return s.timerWarnSeconds;
}
private static Color WarnColor(WLRunUiSettings s)
{
if (s_stageMode)
{
var g = WLStageUiSettings.Instance;
if (g != null && WLStageUiSettings.Enabled) return g.timerWarnColor;
}
return s.timerWarnColor;
}
private static void WriteTimer(float remaining)
{
var s = St;
if (s_timer == null || s == null || !s.timerEnabled) return;
int t = remaining <= 0f ? 0 : Mathf.CeilToInt(remaining);
bool warn = s.timerWarnSeconds > 0f && remaining <= s.timerWarnSeconds;
float warnSec = WarnSeconds(s);
bool warn = warnSec > 0f && remaining <= warnSec;
// WL-813q2(D-23) — 런 시작 직후 1회는 값이 같아도 색을 반드시 다시 쓴다(플래그는 여기서 바로 꺼진다
// → 같은 초 100틱에서 색 쓰기 0회 = GC 0 규약 유지).
if (s_timerColorDirty)
{
s_timerColorDirty = false;
s_shownWarn = warn;
s_timer.color = warn ? s.timerWarnColor : s.timerColor;
s_timer.color = warn ? WarnColor(s) : s.timerColor;
TimerColorWrites++;
}
if (t == s_shownSec && warn == s_shownWarn) return; // 같은 초 = 아무 것도 하지 않는다(GC 0)
s_shownSec = t;
if (warn != s_shownWarn) { s_shownWarn = warn; s_timer.color = warn ? s.timerWarnColor : s.timerColor; TimerColorWrites++; }
if (warn != s_shownWarn) { s_shownWarn = warn; s_timer.color = warn ? WarnColor(s) : s.timerColor; TimerColorWrites++; }
int n = FormatMMSS(s_timeBuf, remaining);
s_timer.SetText(s_timeBuf, 0, n);
TimerWrites++;
@ -290,6 +333,87 @@ namespace WL.UI
if (s_root != null && s_root.gameObject.activeSelf != on) s_root.gameObject.SetActive(on);
}
// ═════════════════════════════════════════════════════════════════════
// WL-815d — 스테이지 모드 (상단 3요소만: 스테이지 N · 남은 시간 · 남은 적 M/T)
// 🔴 값은 전부 815b `StageEvents` 페이로드가 준다 — 이 파일은 그리기만 한다(게임 수치 0).
// 호출은 `StageUiBridge` 하나뿐이다.
// ═════════════════════════════════════════════════════════════════════
/// <summary>스테이지 모드인가(실측 덤프용).</summary>
public static bool StageMode { get { return s_stageMode; } }
/// <summary>지금 화면의 남은 적 줄(실측 덤프용).</summary>
public static string EnemyText { get { return s_enemy != null ? s_enemy.text : ""; } }
/// <summary>지금 화면의 알림 줄(카운트다운 · 실측 덤프용).</summary>
public static string NoticeText { get { return s_notice != null ? s_notice.text : ""; } }
public static bool NoticeVisible { get { return s_notice != null && s_notice.gameObject.activeSelf; } }
/// <summary>존 게이지가 지금 화면에 있는가(스테이지 모드에서는 접힌다 · 실측 덤프용).</summary>
public static bool ZoneGaugeVisible { get { return s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf; } }
/// <summary>남은 적 줄이 지금 화면에 있는가.</summary>
public static bool EnemyVisible { get { return s_enemy != null && s_enemy.gameObject.activeSelf; } }
/// <summary>스테이지 모드 on/off. off 로 되돌리면 813q 런 HUD 표시가 그대로 살아난다.</summary>
public static string SetStageMode(bool on)
{
if (on == s_stageMode) return "스테이지 모드 변화 없음(" + on + ")";
s_stageMode = on;
// 표시 캐시를 비워 다음 Write* 가 반드시 한 번 쓰게 한다(GC 0 규약: 값이 같으면 안 쓴다)
s_shownSec = int.MinValue; s_timerColorDirty = true;
s_shownPhaseKind = -1; s_shownPhaseZone = -1;
s_shownStageNo = int.MinValue;
s_shownEnemyRemain = s_shownEnemyTotal = int.MinValue;
s_shownNotice = null;
if (!on) WriteNotice(null);
ApplyLayout();
return "스테이지 모드=" + on + " · 존게이지=" + (s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf) +
" 남은적줄=" + (s_enemy != null && s_enemy.gameObject.activeSelf);
}
/// <summary>「스테이지 N」 라벨(값이 그대로면 아무 것도 쓰지 않는다).</summary>
public static void WriteStageLabel(int stageNo)
{
var g = WLStageUiSettings.Instance;
if (s_phase == null || g == null) return;
if (stageNo == s_shownStageNo) return;
s_shownStageNo = stageNo;
s_shownPhaseKind = 2; s_shownPhaseZone = stageNo; // 813q WritePhase 의 캐시와 충돌하지 않게
s_phase.SetText(g.stageLabelFormat, stageNo); // TMP 무할당 오버로드({0} = float)
var s = St;
if (s != null) s_phase.color = s.phaseColor;
StageLabelWrites++;
}
/// <summary>「남은 적 M / T」(값이 그대로면 쓰기 0 = GC 0).</summary>
public static void WriteEnemies(int remaining, int total)
{
var g = WLStageUiSettings.Instance;
if (s_enemy == null || g == null) return;
if (remaining == s_shownEnemyRemain && total == s_shownEnemyTotal) return;
s_shownEnemyRemain = remaining; s_shownEnemyTotal = total;
s_enemy.SetText(g.enemyLabelFormat, remaining, total); // TMP 무할당 오버로드(arg0, arg1)
bool hi = g.enemyHighlightAtOrBelow > 0 && remaining > 0 && remaining <= g.enemyHighlightAtOrBelow;
if (hi != s_shownEnemyHi) { s_shownEnemyHi = hi; s_enemy.color = hi ? g.enemyHighlightColor : g.enemyColor; }
EnemyWrites++;
}
/// <summary>스테이지 타이머(남은 초). 경고 구간은 `WLStageUiSettings.timerWarnSeconds`(10 s).</summary>
public static void WriteStageTime(float remaining)
{
s_remaining = remaining;
WriteTimer(remaining);
}
/// <summary>알림 한 줄(카운트다운 「준비」 등). null·빈 문자열이면 숨긴다.</summary>
public static void WriteNotice(string text)
{
if (s_notice == null) return;
bool on = !string.IsNullOrEmpty(text);
if (string.Equals(text, s_shownNotice)) return; // 같은 문구면 쓰지 않는다
s_shownNotice = text;
if (s_notice.gameObject.activeSelf != on) s_notice.gameObject.SetActive(on);
if (on) s_notice.SetText(text);
NoticeWrites++;
}
// ── 매 프레임(러너) — 펄스만 돌린다 ───────────────────────────────────
public static string Tick(float now)
{
@ -336,6 +460,11 @@ namespace WL.UI
s_timer = WLTextFxUtil.NewText(s_root, "Timer", s_font, TextAlignmentOptions.Center);
s_zoneRoot = WLTextFxUtil.NewChild(s_root, "Zones");
s_phase = WLTextFxUtil.NewText(s_root, "Phase", s_font, TextAlignmentOptions.Center);
// WL-815d — 스테이지 모드 전용 2줄(런 모드에서는 SetActive(false) 라 813q 화면은 그대로다)
s_enemy = WLTextFxUtil.NewText(s_root, "Enemies", s_font, TextAlignmentOptions.Center);
s_notice = WLTextFxUtil.NewText(s_root, "Notice", s_font, TextAlignmentOptions.Center);
s_enemy.gameObject.SetActive(false);
s_notice.gameObject.SetActive(false);
s_zoneCells = 0;
}
BuildZoneCells(Mathf.Max(s_zoneCount, RunDirector.ZoneCount));
@ -418,11 +547,18 @@ namespace WL.UI
var canvas = s_root.GetComponentInParent<Canvas>(true);
float u = s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null);
float zoneH = s.zoneGaugeEnabled && s_zoneCells > 0 ? s.zoneCellHeightPx : 0f;
// WL-815d — 스테이지 모드면 존 게이지를 접고(§F-2) 남은 적 줄을 편다.
var g = WLStageUiSettings.Instance;
bool stage = s_stageMode && g != null;
bool zoneOn = s.zoneGaugeEnabled && !(stage && g.hideZoneGaugeInStage);
float enemyH = stage ? g.enemyFontPx : 0f;
float zoneH = zoneOn && s_zoneCells > 0 ? s.zoneCellHeightPx : 0f;
float phaseH = s.phaseLabelEnabled ? s.phaseFontPx : 0f;
float totalH = s.timerFontPx
+ (zoneH > 0f ? s.zoneGaugeTopGapPx + zoneH : 0f)
+ (phaseH > 0f ? s.phaseTopGapPx + phaseH : 0f);
+ (phaseH > 0f ? s.phaseTopGapPx + phaseH : 0f)
+ (enemyH > 0f ? g.enemyTopGapPx + enemyH : 0f);
s_root.anchorMin = s_root.anchorMax = new Vector2(0.5f, 1f);
s_root.pivot = new Vector2(0.5f, 1f);
@ -441,7 +577,7 @@ namespace WL.UI
rt.sizeDelta = new Vector2(0f, s.timerFontPx * u);
s_timer.fontSize = s.timerFontPx * u;
s_timer.alignment = TextAlignmentOptions.Center;
s_timer.color = s_shownWarn ? s.timerWarnColor : s.timerColor;
s_timer.color = s_shownWarn ? WarnColor(s) : s.timerColor;
s_timer.gameObject.SetActive(s.timerEnabled);
LastEdge = WLTextFxUtil.ApplyEdge(s_timer, s.timerEdge);
}
@ -455,7 +591,7 @@ namespace WL.UI
s_zoneRoot.offsetMin = new Vector2(0f, 0f); s_zoneRoot.offsetMax = new Vector2(0f, 0f);
s_zoneRoot.anchoredPosition = new Vector2(0f, y);
s_zoneRoot.sizeDelta = new Vector2(0f, zoneH * u);
s_zoneRoot.gameObject.SetActive(s.zoneGaugeEnabled);
s_zoneRoot.gameObject.SetActive(zoneOn);
int n = Mathf.Max(1, s_zoneCells);
float totalGap = s.zoneCellGapPx * (n - 1) * u;
@ -487,9 +623,48 @@ namespace WL.UI
s_phase.gameObject.SetActive(s.phaseLabelEnabled);
WLTextFxUtil.ApplyEdge(s_phase, s.phaseEdge);
}
y -= phaseH * u;
// WL-815d — ③ 남은 적 M / T (스테이지 모드에서만 편다)
if (s_enemy != null)
{
if (enemyH > 0f)
{
y -= g.enemyTopGapPx * u;
var rt = s_enemy.rectTransform;
rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(0f, 0f); rt.offsetMax = new Vector2(0f, 0f);
rt.anchoredPosition = new Vector2(0f, y);
rt.sizeDelta = new Vector2(0f, enemyH * u);
s_enemy.fontSize = enemyH * u;
s_enemy.alignment = TextAlignmentOptions.Center;
s_enemy.color = s_shownEnemyHi ? g.enemyHighlightColor : g.enemyColor;
WLTextFxUtil.ApplyEdge(s_enemy, s.phaseEdge);
y -= enemyH * u;
}
if (s_enemy.gameObject.activeSelf != (enemyH > 0f)) s_enemy.gameObject.SetActive(enemyH > 0f);
}
// WL-815d — 카운트다운 「준비」 한 줄(3요소 아래 · 표시할 때만 켠다)
if (s_notice != null && g != null)
{
var rt = s_notice.rectTransform;
rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(0f, 0f); rt.offsetMax = new Vector2(0f, 0f);
rt.anchoredPosition = new Vector2(0f, y - g.countdownTopGapPx * u);
rt.sizeDelta = new Vector2(0f, g.countdownFontPx * u);
s_notice.fontSize = g.countdownFontPx * u;
s_notice.alignment = TextAlignmentOptions.Center;
s_notice.color = g.countdownColor;
WLTextFxUtil.ApplyEdge(s_notice, s.timerEdge);
}
return "pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta +
" 칸=" + s_zoneCells + " unitsPerPx=" + u.ToString("F4");
" 칸=" + s_zoneCells + " 스테이지모드=" + s_stageMode +
" 존게이지=" + zoneOn + " 남은적=" + (enemyH > 0f) +
" unitsPerPx=" + u.ToString("F4");
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
@ -498,11 +673,16 @@ namespace WL.UI
{
StartedSeen = TickSeen = ZoneClearedSeen = GateSeen = BossSeen = EndedSeen = 0;
TimerWrites = PhaseWrites = FillWrites = TimerColorWrites = 0;
StageLabelWrites = EnemyWrites = NoticeWrites = StageEventsIgnored = 0; // WL-815d
s_shownSec = int.MinValue; s_shownWarn = false; s_timerColorDirty = false;
s_shownPhaseKind = -1; s_shownPhaseZone = -1;
s_shownStageNo = int.MinValue;
s_shownEnemyRemain = s_shownEnemyTotal = int.MinValue; s_shownEnemyHi = false;
s_shownNotice = null;
if (s_notice != null && s_notice.gameObject.activeSelf) s_notice.gameObject.SetActive(false);
s_bossPhase = false; s_remaining = 0f;
ResetZoneCells();
return "상태 초기화(칸 " + s_zoneCells + ")";
return "상태 초기화(칸 " + s_zoneCells + " · 스테이지모드 " + s_stageMode + ")";
}
/// <summary>노드까지 되돌린다(프로브 종료용).</summary>
@ -515,6 +695,7 @@ namespace WL.UI
else Object.DestroyImmediate(s_root.gameObject);
}
s_root = null; s_timer = null; s_phase = null; s_zoneRoot = null; s_uiRoot = null;
s_enemy = null; s_notice = null; s_stageMode = false; // WL-815d
s_zoneCell = new RectTransform[0]; s_zoneBg = new Image[0]; s_zoneFill = new Image[0];
s_zonePulseAt = new float[0]; s_zoneFillValue = new float[0];
s_zoneCells = 0; s_visible = false;
@ -550,6 +731,11 @@ namespace WL.UI
" active=" + s_root.gameObject.activeSelf + " 부모=" +
(s_root.parent != null ? s_root.parent.name : "-") +
" SafeArea=" + (s_root.GetComponentInParent<SafeAreaFitter>(true) != null));
sb.AppendLine(" [815d] 스테이지모드=" + s_stageMode + " 단계=\"" + PhaseText + "\"(쓰기 " + StageLabelWrites +
") 남은적=\"" + EnemyText + "\"(쓰기 " + EnemyWrites + ") 알림=\"" + NoticeText +
"\"(보임 " + NoticeVisible + " · 쓰기 " + NoticeWrites + ") · 무시한 RunEvents=" + StageEventsIgnored +
" · 존게이지활성=" + (s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf) +
" · 경고초=" + (s != null ? WarnSeconds(s).ToString("F0") : "-") + "s");
sb.AppendLine(" edge = " + LastEdge);
if (s != null)
sb.AppendLine(" 설정 enabled=" + s.enabled_ + " hud=" + s.runHudEnabled + " topMargin=" + s.runHudTopMarginPx +

View File

@ -100,6 +100,26 @@ namespace WL.UI
private static readonly StringBuilder s_sb = new StringBuilder(128);
private static readonly char[] s_timeBuf = new char[8];
// ── WL-815d 스테이지 모드 ─────────────────────────────────────────────
// 기준서 §F-1 — 같은 패널을 스테이지 클리어 화면으로 쓴다.
// · 제목 = "스테이지 N 클리어!" · 행 = `WLStageUiSettings.stageResultRows`
// · 버튼 = [다음 스테이지] → StageDirector.Next() / [로비로] → 「로비로」(StageUiBridge 가 대입)
// 🔴 값의 출처는 **`StageClearResult` 페이로드 + UI 자체 집계**다. 815b `mirrorRunEnded` 가
// 내보내는 RunResult 에는 레벨·골드·등급별 획득이 없다(실측) → 그 행은 기본 배열에서 뺐다.
private static bool s_stageMode;
private static WL.Combat.Stage.StageClearResult s_stage;
private static bool s_hasStage;
private static int s_stageHits, s_stagePotions, s_stageMaxChain;
private static TextMeshProUGUI s_badge; // 무피격 클리어 배지
private static bool s_suppressNextRunEnded; // 실패 경로 — 병행 RunEnded 로 결과창이 겹쳐 뜨는 것을 막는다
/// <summary>815d 가 대입한다 — [다음 스테이지] / [처음부터].</summary>
public static System.Action StagePrimary;
/// <summary>815d 가 대입한다 — [로비로].</summary>
public static System.Action StageSecondary;
public static int StagePrimaryCount, StageSecondaryCount, StageShownCount, SuppressedRunEndedCount;
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
public static int EndedSeen, ShownCount, DeferredCount, NextRunCount, CloseCount, SkipAbandonCount;
public static int BufferGrowCount; // 복사 버퍼를 새로 잡은 횟수(2번째 런부터 0 = GC 0)
@ -247,6 +267,16 @@ namespace WL.UI
CopyResult(in e);
var s = St;
if (s == null || !WLRunUiSettings.Enabled || !s.resultEnabled) return;
// WL-815d — 스테이지 실패(사망·시간 초과)는 813z 2버튼 팝업이 맡는다. 815b `mirrorRunEnded` 가
// 같은 프레임에 내보내는 RunEnded(Timeout/Abandon)로 결과창이 **겹쳐 뜨는 것**을 여기서 막는다.
if (s_suppressNextRunEnded)
{
s_suppressNextRunEnded = false;
SuppressedRunEndedCount++;
s_pending = false;
if (s.verboseLog) Debug.Log("[RunResultUI] 815d — 스테이지 실패 경로: 병행 RunEnded 억제");
return;
}
if (e.outcome == RunOutcome.Abandon && !s.resultShowOnAbandon)
{
SkipAbandonCount++;
@ -449,18 +479,225 @@ namespace WL.UI
if (s_group != null) { s_group.alpha = 0f; s_group.blocksRaycasts = false; s_group.interactable = false; }
}
/// <summary>[다음 런] — 813p 공개 API 하나만 부른다.</summary>
/// <summary>[다음 런] — 813p 공개 API 하나만 부른다. 🔴 WL-815d: 스테이지 모드면 [다음 스테이지] 로 동작한다.</summary>
public static string OnNextRun()
{
NextRunCount++;
Hide();
if (s_stageMode)
{
StagePrimaryCount++;
if (StagePrimary == null) return "다음 스테이지 — 815d 핸들러 미연결(무동작)";
try { StagePrimary(); }
catch (System.Exception e) { return "다음 스테이지 — 예외(" + e.GetType().Name + ")"; }
return "다음 스테이지 — StageDirector 호출";
}
try { RunDirector.Restart(); }
catch (System.Exception e) { return "다음 런 — RunDirector.Restart() 예외(" + e.GetType().Name + ")"; }
return "다음 런 — RunDirector.Restart() 호출";
}
/// <summary>[닫기].</summary>
public static string OnClose() { CloseCount++; Hide(); return "닫기"; }
/// <summary>[닫기] — 🔴 WL-815d: 스테이지 모드면 [로비로] 로 동작한다.</summary>
public static string OnClose()
{
CloseCount++;
Hide();
if (s_stageMode)
{
StageSecondaryCount++;
if (StageSecondary == null) return "로비로 — 815d 핸들러 미연결(무동작)";
try { StageSecondary(); }
catch (System.Exception e) { return "로비로 — 예외(" + e.GetType().Name + ")"; }
return "로비로 — StageDirector 호출";
}
return "닫기";
}
// ═════════════════════════════════════════════════════════════════════
// WL-815d — 스테이지 클리어 화면 (기준서 §F-1)
// ═════════════════════════════════════════════════════════════════════
/// <summary>스테이지 모드인가(실측 덤프용).</summary>
public static bool StageMode { get { return s_stageMode; } }
/// <summary>마지막으로 받은 스테이지 결과(실측 덤프용).</summary>
public static WL.Combat.Stage.StageClearResult StageResultCopy { get { return s_stage; } }
public static bool BadgeVisible { get { return s_badge != null && s_badge.gameObject.activeSelf; } }
public static string BadgeText { get { return s_badge != null ? s_badge.text : ""; } }
/// <summary>스테이지 모드 on/off(off 로 되돌리면 813q 런 결과 화면 그대로).</summary>
public static string SetStageMode(bool on)
{
s_stageMode = on;
if (!on) { s_hasStage = false; if (s_badge != null && s_badge.gameObject.activeSelf) s_badge.gameObject.SetActive(false); }
return "결과창 스테이지 모드=" + on;
}
/// <summary>다음 `RunEnded` 1건을 무시한다(스테이지 실패 경로 · 결과창 중복 표시 0).</summary>
public static string SuppressNextRunEnded()
{
s_suppressNextRunEnded = true;
s_pending = false;
return "다음 RunEnded 1건 억제 예약";
}
/// <summary>
/// 스테이지 클리어 결과를 받아 화면을 띄운다(부활 팝업이 떠 있으면 813q2 규칙대로 미룬다).
/// hits/potions/maxChain 은 <see cref="StageUiBridge"/> 가 스테이지 구간 동안 실측한 값이다.
/// </summary>
public static string ShowStage(in WL.Combat.Stage.StageClearResult r, int hits, int potions, int maxChain)
{
var s = St;
var g = WLStageUiSettings.Instance;
if (s == null) return "WLRunUiSettings 에셋 없음";
if (g == null || !WLStageUiSettings.Enabled || !g.stageResultEnabled) return "815d off — 스테이지 결과 생략";
if (!WLRunUiSettings.Enabled || !s.resultEnabled) return "813q off — 결과 화면 생략";
s_stage = r; s_hasStage = true;
s_stageHits = hits; s_stagePotions = potions; s_stageMaxChain = maxChain;
s_stageMode = true;
// 🔴 병행 RunEnded 는 StageCleared **뒤에** 온다(815b `Clear()` 실측) → 엘리트 스냅샷을 여기서 뜬다.
s_eliteKillsAtEnd = s_eliteKills;
s_cardPicksAtEnd = ZonePickup.PicksThisRun;
StageShownCount++;
s_suppressNextRunEnded = true; // 뒤따라오는 병행 RunEnded(Win) 는 무시(중복 표시 0)
s_pending = true;
return TryShow(Time.unscaledTime);
}
/// <summary>스테이지 결과 제목(마지막 스테이지면 다른 문구).</summary>
private static string StageTitle(WLStageUiSettings g)
{
string f = s_stage.isLastStage ? g.stageAllClearTitleFormat : g.stageClearTitleFormat;
return FormatOne(f, s_stage.stageId > 0 ? s_stage.stageId : s_stage.index + 1);
}
/// <summary>"{0}" 한 자리 치환(정수 전용 · string.Format 없이 · GC 최소).</summary>
private static string FormatOne(string format, long a0)
{
if (string.IsNullOrEmpty(format)) return a0.ToString();
s_sb.Length = 0;
AppendFormat2(s_sb, format, a0, 0);
return s_sb.ToString();
}
/// <summary>스테이지 본문(기준서 §F-1). 813q 의 행 노드를 그대로 재사용한다.</summary>
private static string BuildStageBody(WLRunUiSettings s, WLStageUiSettings g)
{
EnsureStageRows(s, g);
var body = new StringBuilder(256);
string title = StageTitle(g);
body.Append(title).Append('\n');
int slot = 0;
var rows = g.stageResultRows != null ? g.stageResultRows : new WLStageResultRow[0];
for (int i = 0; i < rows.Length; i++)
{
var kind = rows[i];
if (kind == WLStageResultRow.BossClear && !s_stage.isBoss) continue; // 보스 스테이지에서만
string label = StageLabelOf(g, kind);
s_sb.Length = 0;
StageValueOf(g, kind, s_sb);
string value = s_sb.ToString();
if (slot < s_row.Length)
{
if (s_rowLabel[slot] != null) s_rowLabel[slot].SetText(label);
if (s_rowValue[slot] != null) s_rowValue[slot].SetText(value);
if (s_row[slot] != null && !s_row[slot].gameObject.activeSelf) s_row[slot].gameObject.SetActive(true);
}
body.Append(label).Append(" : ").Append(value).Append('\n');
slot++;
}
for (int i = slot; i < s_row.Length; i++)
if (s_row[i] != null && s_row[i].gameObject.activeSelf) s_row[i].gameObject.SetActive(false);
VisibleRows = slot;
if (s_title != null)
{
s_title.SetText(title);
s_title.color = s.resultTitleWinColor;
}
// 무피격 배지(기준서 §F-1 「제안」) — 피격 0회 클리어일 때만
bool badge = g.noHitBadgeEnabled && s_stage.win && s_stageHits <= 0;
if (s_badge != null)
{
if (s_badge.gameObject.activeSelf != badge) s_badge.gameObject.SetActive(badge);
if (badge) { s_badge.SetText(g.noHitBadgeText); s_badge.color = g.noHitBadgeColor; body.Append(g.noHitBadgeText).Append('\n'); }
}
BodyText = body.ToString();
return "스테이지 행 " + slot + "개 · 배지=" + badge;
}
private static void EnsureStageRows(WLRunUiSettings s, WLStageUiSettings g)
{
int want = g.stageResultRows != null ? g.stageResultRows.Length : 0;
if (s_rowRoot == null || s_row.Length >= want) return;
int old = s_row.Length;
System.Array.Resize(ref s_row, want);
System.Array.Resize(ref s_rowLabel, want);
System.Array.Resize(ref s_rowValue, want);
for (int i = old; i < want; i++)
{
var rt = WLTextFxUtil.NewChild(s_rowRoot, "Row" + i);
s_row[i] = rt;
s_rowLabel[i] = WLTextFxUtil.NewText(rt, "Label", s_font, TextAlignmentOptions.MidlineLeft);
s_rowValue[i] = WLTextFxUtil.NewText(rt, "Value", s_font, TextAlignmentOptions.MidlineRight);
}
}
private static string StageLabelOf(WLStageUiSettings g, WLStageResultRow row)
{
switch (row)
{
case WLStageResultRow.ClearTime: return g.rowClearTimeLabel;
case WLStageResultRow.Kills: return g.rowKillsLabel;
case WLStageResultRow.EliteKills: return g.rowEliteLabel;
case WLStageResultRow.MaxChain: return g.rowMaxChainLabel;
case WLStageResultRow.HitsPotions: return g.rowHitsPotionsLabel;
case WLStageResultRow.Retries: return g.rowRetriesLabel;
case WLStageResultRow.BossClear: return g.rowBossLabel;
case WLStageResultRow.Level: return g.rowLevelLabel;
case WLStageResultRow.Gold: return g.rowGoldLabel;
default: return g.rowLootLabel;
}
}
private static void StageValueOf(WLStageUiSettings g, WLStageResultRow row, StringBuilder sb)
{
var s = St;
switch (row)
{
case WLStageResultRow.ClearTime:
AppendTime(sb, s_stage.elapsedSec, s);
break;
case WLStageResultRow.Kills:
AppendFormat2(sb, g.rowKillsFormat, s_stage.killed, s_stage.total);
break;
case WLStageResultRow.EliteKills:
sb.Append(s != null && s.resultCountElitesInUi ? s_eliteKillsAtEnd : 0);
break;
case WLStageResultRow.MaxChain:
sb.Append(s_stageMaxChain);
break;
case WLStageResultRow.HitsPotions:
AppendFormat2(sb, g.rowHitsPotionsFormat, s_stageHits, s_stagePotions);
break;
case WLStageResultRow.Retries:
AppendFormat2(sb, g.rowRetriesFormat, s_stage.retries, 0);
break;
case WLStageResultRow.BossClear:
sb.Append(s_stage.win ? g.rowBossClearedText : g.rowUnknownText);
break;
default:
// 🔴 레벨 · 골드 · 등급별 획득 = 815b 페이로드에 없다(실측) → 「미확인」 표기
sb.Append(g.rowUnknownText);
break;
}
}
private static void OnNextRunListener() { OnNextRun(); }
private static void OnCloseListener() { OnClose(); }
@ -506,6 +743,12 @@ namespace WL.UI
public static string BuildBody(WLRunUiSettings s)
{
if (s == null) return "설정 없음";
// WL-815d — 스테이지 클리어면 §F-1 행으로 그린다(813q 런 행 구성은 그대로 둔다).
if (s_stageMode && s_hasStage)
{
var g = WLStageUiSettings.Instance;
if (g != null && WLStageUiSettings.Enabled && g.stageResultEnabled) return BuildStageBody(s, g);
}
EnsureRows(s);
var body = new StringBuilder(256);
@ -722,6 +965,8 @@ namespace WL.UI
s_boxBg.raycastTarget = true;
s_title = WLTextFxUtil.NewText(s_box, "Title", s_font, TextAlignmentOptions.Center);
s_badge = WLTextFxUtil.NewText(s_box, "Badge", s_font, TextAlignmentOptions.Center); // WL-815d 무피격 배지
s_badge.gameObject.SetActive(false);
s_rowRoot = WLTextFxUtil.NewChild(s_box, "Rows");
s_nextButton = NewButton(s_box, "btn_next", out s_nextBg, out s_nextLabel, OnNextRunListener);
s_closeButton = NewButton(s_box, "btn_close", out s_closeBg, out s_closeLabel, OnCloseListener);
@ -806,7 +1051,25 @@ namespace WL.UI
WLTextFxUtil.ApplyEdge(s_title, s.resultEdge);
}
float rowTop = (s.resultPadTopPx + s.resultTitleFontPx + s.resultTitleGapPx) * u;
// WL-815d — 무피격 배지(제목 바로 아래 · 켜져 있을 때만 본문을 밀어 내린다)
var g815 = WLStageUiSettings.Instance;
float badgeH = 0f;
if (s_badge != null && g815 != null)
{
badgeH = s_badge.gameObject.activeSelf ? g815.noHitBadgeFontPx : 0f;
var rt = s_badge.rectTransform;
rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(s.resultPadXPx * u, 0f); rt.offsetMax = new Vector2(-s.resultPadXPx * u, 0f);
rt.anchoredPosition = new Vector2(0f, -(s.resultPadTopPx + s.resultTitleFontPx + 8f) * u);
rt.sizeDelta = new Vector2(0f, g815.noHitBadgeFontPx * u);
s_badge.fontSize = g815.noHitBadgeFontPx * u;
s_badge.alignment = TextAlignmentOptions.Center;
s_badge.color = g815.noHitBadgeColor;
WLTextFxUtil.ApplyEdge(s_badge, s.resultEdge);
}
float rowTop = (s.resultPadTopPx + s.resultTitleFontPx + s.resultTitleGapPx) * u + badgeH * u;
if (s_rowRoot != null)
{
s_rowRoot.anchorMin = new Vector2(0f, 1f); s_rowRoot.anchorMax = new Vector2(1f, 1f);
@ -829,10 +1092,24 @@ namespace WL.UI
}
}
LayoutButton(s_nextButton, s_nextBg, s_nextLabel, s, u, -1, s.resultNextRunText, s.resultNextBgColor, s.resultNextLabelColor);
LayoutButton(s_closeButton, s_closeBg, s_closeLabel, s, u, 1, s.resultCloseText, s.resultCloseBgColor, s.resultCloseLabelColor);
// WL-815d — 버튼 문구: 스테이지 모드면 [다음 스테이지](마지막이면 [처음부터]) / [로비로]
string leftText = s.resultNextRunText, rightText = s.resultCloseText;
bool leftOn = true;
if (s_stageMode && g815 != null)
{
bool last = s_hasStage && s_stage.isLastStage;
leftText = last ? g815.restartFromFirstText : g815.nextStageText;
rightText = g815.toLobbyText;
leftOn = !last || g815.showRestartOnLastStage;
}
LayoutButton(s_nextButton, s_nextBg, s_nextLabel, s, u, leftOn ? -1 : 0, leftText, s.resultNextBgColor, s.resultNextLabelColor);
LayoutButton(s_closeButton, s_closeBg, s_closeLabel, s, u, leftOn ? 1 : 0, rightText, s.resultCloseBgColor, s.resultCloseLabelColor);
if (s_nextButton != null && s_nextButton.gameObject.activeSelf != leftOn) s_nextButton.gameObject.SetActive(leftOn);
return "box size" + s_box.sizeDelta + " 행틀=" + s_row.Length + " unitsPerPx=" + u.ToString("F4");
return "box size" + s_box.sizeDelta + " 행틀=" + s_row.Length +
" 스테이지모드=" + s_stageMode + " 버튼=[" + leftText + "](" + leftOn + ") [" + rightText + "]" +
" 배지=" + (s_badge != null && s_badge.gameObject.activeSelf) +
" unitsPerPx=" + u.ToString("F4");
}
private static void ApplyRowLabel(TextMeshProUGUI t, WLRunUiSettings s, float u, bool left)
@ -877,6 +1154,7 @@ namespace WL.UI
s_font = f;
if (f == null) return;
if (s_title != null) s_title.font = f;
if (s_badge != null) s_badge.font = f; // WL-815d
if (s_nextLabel != null) s_nextLabel.font = f;
if (s_closeLabel != null) s_closeLabel.font = f;
for (int i = 0; i < s_row.Length; i++)
@ -897,6 +1175,11 @@ namespace WL.UI
s_cardPicksAtEnd = 0; LastBossHpSource = "없음"; // WL-813q3
s_lowered = false; LastDeathLog = "";
s_pending = false; UsedCommonPopup = false; LastShowLog = "";
// WL-815d
StagePrimaryCount = StageSecondaryCount = StageShownCount = SuppressedRunEndedCount = 0;
s_hasStage = false; s_suppressNextRunEnded = false;
s_stageHits = s_stagePotions = s_stageMaxChain = 0;
if (s_badge != null && s_badge.gameObject.activeSelf) s_badge.gameObject.SetActive(false);
Hide();
return "상태 초기화";
}
@ -912,6 +1195,7 @@ namespace WL.UI
s_root = null; s_canvas = null; s_raycaster = null; s_group = null; s_dim = null; s_box = null; s_boxBg = null;
s_title = null; s_rowRoot = null; s_nextButton = null; s_closeButton = null;
s_nextLabel = null; s_closeLabel = null; s_nextBg = null; s_closeBg = null;
s_badge = null; s_stageMode = false; // WL-815d
s_row = new RectTransform[0]; s_rowLabel = new TextMeshProUGUI[0]; s_rowValue = new TextMeshProUGUI[0];
s_uiRoot = null;
ResetState();
@ -928,6 +1212,15 @@ namespace WL.UI
" abandon생략=" + SkipAbandonCount + " 버퍼증설=" + BufferGrowCount +
" 공용팝업=" + UsedCommonPopup);
sb.AppendLine(" 제목 \"" + TitleText + "\" · 행 " + VisibleRows + "개 · 부활팝업Busy=" + ReviveDialog.Busy);
sb.AppendLine(" [815d] 스테이지모드=" + s_stageMode + " 결과있음=" + s_hasStage +
" 표시 " + StageShownCount + "회 · 버튼 다음=" + StagePrimaryCount + " 로비=" + StageSecondaryCount +
"(핸들러 " + (StagePrimary != null) + "/" + (StageSecondary != null) + ")" +
" · RunEnded 억제 " + SuppressedRunEndedCount + "회(예약=" + s_suppressNextRunEnded + ")" +
" · 배지=" + BadgeVisible + "\"" + BadgeText + "\"" +
" · 스테이지 idx=" + s_stage.index + " id=" + s_stage.stageId + " win=" + s_stage.win +
" " + s_stage.elapsedSec.ToString("F1") + "s " + s_stage.killed + "/" + s_stage.total +
" 재도전=" + s_stage.retries + " 마지막=" + s_stage.isLastStage + " 보스=" + s_stage.isBoss +
" · 피격=" + s_stageHits + " 물약=" + s_stagePotions + " 최대연쇄=" + s_stageMaxChain);
sb.AppendLine(" [813q2] 부활우선=" + ReviveBusy + "(" + ReviveBusyReason + ") · DeathFlow=" + DeathFlow.State +
" 런=" + RunDirector.Phase + "(running=" + RunDirector.IsRunning + ") · died=" + DiedSeen + " request=" + ReviveRequestSeen +
" revived=" + RevivedSeen + " 팝업생략부활=" + SkipPopupReviveCount +

View File

@ -0,0 +1,677 @@
// ─────────────────────────────────────────────────────────────────────────────
// StageUiBridge.cs — 815b 스테이지 축 ↔ 813q/813z UI 를 잇는 **유일한 이음매** (WL-815d · #815)
//
// PD 지시 #815 + 추가 지시(2026-09-11 00:2x) · 발주서 WL-815d §1-3 ~ §1-6.
// SOT = 815b 보고 §3(계약 API) · 기준서 §E(실패·재도전) · §F-1(클리어 화면) · §F-2(전투 HUD)
// + 발주서 WL-815a §3(§J 원문 — 「불필요한 메뉴는 전부 제거 … 상단에는 스테이지 정보와
// 남은 시간, 그리고 처치할 적의 마릿수 정보만」)
//
// ■ 이 파일이 하는 일 (전부 「그리기 쪽」이다 — 게임 판정 0 · 게임 수치 0)
// ① StageEvents 8종을 구독해 813q `RunHud` 를 **스테이지 모드**로 몬다(상단 3요소만).
// ② 스테이지 중에만 원본 메뉴를 `SetActive(false)` 하고 **이탈 시 원래 값으로 되돌린다**
// (숨길 목록 = `WLStageUiSettings.stageHidePaths` · 원본 프리팹 diff 0 · 813y 와 같은 방식).
// ③ `StageCleared` → 813q `RunResultUI` 를 스테이지 클리어 화면으로 띄운다([다음 스테이지] / [로비로]).
// ④ `StageFailed` → 813z `ReviveDialog` 를 2버튼 실패 팝업으로 띄운다([다시 도전] / [로비로]).
// ⑤ 「피격 횟수 · 물약 사용 · 최대 연쇄」를 스테이지 구간 동안 **UI 가 직접 실측**한다
// (815b `mirrorRunEnded` 페이로드에 없는 값 — 813q2 가 엘리트를 세는 것과 같은 방식).
//
// ■ C8 롤백
// `WLStageUiSettings.enabled_ = 0`(또는 `RuntimeDisabled`) → 구독 0 · 메뉴 숨김 0 · 스테이지 모드 0.
// 그러면 813q 런 HUD·결과창과 813z 부활 팝업이 **원래대로** 돈다(스테이지 축 자체는 계속 돈다).
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 가 플레이어 팝업을 띄운다).
// 🔴 `StageResult`(원본 전역 클래스)와 이름이 겹치지 않게 `WL.Combat.Stage` 를 **using 하지 않고**
// 전부 정규화 이름으로 쓴다(815b 보고 §3 의 CS0453 실측).
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using UnityEngine;
using WL.Combat.Core;
using WL.Combat.Reaction;
using WL.Combat.Survival;
namespace WL.UI
{
/// <summary>스테이지 UI 이음매(정적 · 노드 0 · 값은 전부 SO).</summary>
public static class StageUiBridge
{
// ── 구독 상태 ─────────────────────────────────────────────────────────
private static bool s_subscribed;
private static bool s_inStage;
// ── 스테이지 구간 실측(UI 자체 집계 · 813q2 엘리트와 같은 방식) ───────
private static int s_hits; // 이번 스테이지에서 PC 가 맞은 횟수
private static int s_potionsAtStart; // 스테이지 시작 시점의 PotionUse.UsedCount
private static int s_maxChain; // 이번 스테이지 최대 연쇄
private static int s_stageNo; // 화면에 쓴 스테이지 번호(1부터)
// ── 메뉴 숨김 복원 표 (GC 0 — 배열은 한 번만 잡는다) ──────────────────
private static Transform[] s_hidden = new Transform[0];
private static bool[] s_hiddenPrev = new bool[0];
private static int s_hiddenCount;
private static Transform s_uiRoot;
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
public static int EnteredSeen, CountdownSeen, StartedSeen, EnemySeen, TimeSeen, ClearedSeen, FailedSeen, ExitedSeen;
public static int MenuHideCalls, MenuRestoreCalls, MenuMissing;
public static int MenuReassertCalls, MenuReassertHits; // 원본이 다시 켠 것을 도로 끈 횟수
public static int RetryCalls, NextCalls, LobbyCalls, RestartCalls;
public static string LastLog = "";
public static string LastMissingPath = "";
public static bool InStage { get { return s_inStage; } }
public static bool Subscribed { get { return s_subscribed; } }
public static int HiddenCount { get { return s_hiddenCount; } }
public static int Hits { get { return s_hits; } }
public static int MaxChain { get { return s_maxChain; } }
public static int PotionsUsed { get { int d = PotionUse.UsedCount - s_potionsAtStart; return d > 0 ? d : 0; } }
public static int StageNo { get { return s_stageNo; } }
private static WLStageUiSettings St { get { return WLStageUiSettings.Instance; } }
// ── 부팅 ──────────────────────────────────────────────────────────────
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Boot()
{
if (!WLStageUiSettings.Enabled) return; // C8 — 구독 0 · 오브젝트 0
Subscribe(true);
StageUiRunner.Ensure();
}
// ── 구독 (815b StageEvents 8종 + UI 자체 집계 2종) ────────────────────
public static string Subscribe(bool on)
{
if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")";
if (on)
{
WL.Combat.Stage.StageEvents.StageEntered.Add(OnEntered);
WL.Combat.Stage.StageEvents.CountdownTick.Add(OnCountdown);
WL.Combat.Stage.StageEvents.StageStarted.Add(OnStarted);
WL.Combat.Stage.StageEvents.EnemyCountChanged.Add(OnEnemyCount);
WL.Combat.Stage.StageEvents.TimeChanged.Add(OnTime);
WL.Combat.Stage.StageEvents.StageCleared.Add(OnCleared);
WL.Combat.Stage.StageEvents.StageFailed.Add(OnFailed);
WL.Combat.Stage.StageEvents.StageExited.Add(OnExited);
CombatEvents.Damaged.Add(OnDamaged);
KillChain.Ended.Add(OnChainEnded);
BindButtons();
}
else
{
WL.Combat.Stage.StageEvents.StageEntered.Remove(OnEntered);
WL.Combat.Stage.StageEvents.CountdownTick.Remove(OnCountdown);
WL.Combat.Stage.StageEvents.StageStarted.Remove(OnStarted);
WL.Combat.Stage.StageEvents.EnemyCountChanged.Remove(OnEnemyCount);
WL.Combat.Stage.StageEvents.TimeChanged.Remove(OnTime);
WL.Combat.Stage.StageEvents.StageCleared.Remove(OnCleared);
WL.Combat.Stage.StageEvents.StageFailed.Remove(OnFailed);
WL.Combat.Stage.StageEvents.StageExited.Remove(OnExited);
CombatEvents.Damaged.Remove(OnDamaged);
KillChain.Ended.Remove(OnChainEnded);
UnbindButtons();
}
s_subscribed = on;
return "구독=" + on + " (StageEntered/Countdown/Started/EnemyCount/Time/Cleared/Failed/Exited + Damaged/KillChain.Ended)";
}
/// <summary>결과창·실패 팝업의 버튼을 스테이지 API 로 잇는다(813q/813z 파일은 델리게이트만 본다).</summary>
public static string BindButtons()
{
// 🔴 `System.Action` 은 void() 라 문자열을 돌려주는 실동작 함수를 그대로 못 넣는다(CS0407 실측).
// 반환 문자열은 프로브·로그가 읽어야 하므로 **void 래퍼**로 감싼다(람다 0 = 델리게이트 동일성 유지).
RunResultUI.StagePrimary = NextStageListener;
RunResultUI.StageSecondary = ToLobbyListener;
ReviveDialog.PrimaryOverride = RetryListener;
ReviveDialog.SecondaryAction = ToLobbyListener;
return "버튼 연결 — 결과창[다음 스테이지/로비로] · 실패팝업[다시 도전/로비로]";
}
private static void NextStageListener() { OnNextStage(); }
private static void ToLobbyListener() { OnToLobby(); }
private static void RetryListener() { OnRetry(); }
public static string UnbindButtons()
{
RunResultUI.StagePrimary = null;
RunResultUI.StageSecondary = null;
ReviveDialog.PrimaryOverride = null;
ReviveDialog.SecondaryAction = null;
return "버튼 해제 — 813q/813z 원래 동작([다음 런]/[닫기] · [부활])";
}
// ═════════════════════════════════════════════════════════════════════
// StageEvents 핸들러 (in 파라미터 = 복사 0 · 815b 계약)
// ═════════════════════════════════════════════════════════════════════
private static void OnEntered(in WL.Combat.Stage.StageEnteredEvent e)
{
EnteredSeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled) return;
s_inStage = true;
s_stageNo = e.stageId > 0 ? e.stageId : e.index + 1;
s_hits = 0;
s_maxChain = 0;
s_potionsAtStart = PotionUse.UsedCount;
if (g.stageHudEnabled)
{
RunHud.SetStageMode(true);
RunHud.SetVisible(true);
RunHud.WriteStageLabel(s_stageNo);
RunHud.WriteEnemies(e.totalEnemies, e.totalEnemies);
RunHud.WriteStageTime(e.timeLimit);
}
RunResultUI.SetStageMode(g.stageResultEnabled);
ApplyMenuHide(true);
ClearFailPopup();
Log(g, "입장 스테이지 " + s_stageNo + " — 고정 수 " + e.totalEnemies + " · 제한 " + e.timeLimit.ToString("F0") + "s");
}
private static void OnCountdown(in WL.Combat.Stage.StageCountdownEvent e)
{
CountdownSeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled || !g.stageHudEnabled) return;
if (!g.countdownEnabled) return;
if (!string.IsNullOrEmpty(g.countdownText)) { RunHud.WriteNotice(g.countdownText); return; }
int n = e.remaining <= 0f ? 0 : Mathf.CeilToInt(e.remaining);
RunHud.WriteNotice(FormatOne(g.countdownNumberFormat, n));
}
private static void OnStarted(in WL.Combat.Stage.StageStartedEvent e)
{
StartedSeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled) return;
s_potionsAtStart = PotionUse.UsedCount; // 재도전마다 다시 잰다(기준서 §E — 재도전 시 물약 초기화)
s_hits = 0; s_maxChain = 0;
if (!g.stageHudEnabled) return;
RunHud.WriteNotice(null); // 카운트다운 줄을 접는다
RunHud.WriteStageLabel(e.stageId > 0 ? e.stageId : e.index + 1);
RunHud.WriteStageTime(e.timeLimit);
}
private static void OnEnemyCount(in WL.Combat.Stage.StageEnemyCountEvent e)
{
EnemySeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled || !g.stageHudEnabled) return;
RunHud.WriteEnemies(e.remaining, e.total);
}
private static void OnTime(in WL.Combat.Stage.StageTimeEvent e)
{
TimeSeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled || !g.stageHudEnabled) return;
RunHud.WriteStageTime(e.remaining);
}
private static void OnCleared(in WL.Combat.Stage.StageClearResult r)
{
ClearedSeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled) return;
if (g.stageHudEnabled) RunHud.WriteNotice(null);
if (!g.stageResultEnabled) return;
LastLog = RunResultUI.ShowStage(in r, s_hits, PotionsUsed, s_maxChain);
Log(g, "클리어 스테이지 " + (r.stageId > 0 ? r.stageId : r.index + 1) + " — " + LastLog);
}
private static void OnFailed(in WL.Combat.Stage.StageFailedEvent e)
{
FailedSeen++;
var g = St;
if (g == null || !WLStageUiSettings.Enabled) return;
if (g.stageHudEnabled) RunHud.WriteNotice(null);
if (!g.stageFailEnabled) return;
// 🔴 815b `mirrorRunEnded` 가 같은 프레임에 RunEnded(Timeout/Abandon)를 낸다 → 813q 결과창 억제
if (g.suppressResultOnFail) RunResultUI.SuppressNextRunEnded();
string msg;
if (e.reason == WL.Combat.Stage.StageFailReason.TimeLimit) msg = FormatOne(g.failTimeoutMessage, e.remaining);
else if (e.reason == WL.Combat.Stage.StageFailReason.PlayerDied) msg = g.failDeathMessage;
else msg = g.failAbortedMessage;
var dlg = ReviveDialog.Active;
if (dlg == null) { LastLog = "실패 — ReviveDialog 인스턴스 없음(무동작)"; return; }
LastLog = dlg.ShowStageFail(msg, g.failRetryText, g.failLobbyText);
Log(g, "실패(" + e.reason + ") — " + LastLog);
}
private static void OnExited(in WL.Combat.Stage.StageExitedEvent e)
{
ExitedSeen++;
s_inStage = false;
var g = St;
if (g == null) return;
if (g.stageHudEnabled)
{
RunHud.WriteNotice(null);
RunHud.SetStageMode(false);
RunHud.SetVisible(false);
}
RunResultUI.SetStageMode(false);
ClearFailPopup();
string restore = ApplyMenuHide(false); // 🔴 이탈 = 반드시 복원
Log(g, "이탈(" + e.reason + ") — 로비=" + e.toLobby + " · " + restore);
}
// ── UI 자체 집계 (815b 페이로드에 없는 값) ────────────────────────────
private static void OnDamaged(in DamagedEvent e)
{
if (!s_inStage) return;
if (!MyValue.bMyPC || MyValue.MyPC == null) return;
if (!ReferenceEquals(e.victim, MyValue.MyPC)) return;
s_hits++;
}
private static void OnChainEnded(in KillChainEndedEvent e)
{
if (!s_inStage) return;
if (e.kills > s_maxChain) s_maxChain = e.kills;
}
private static float s_nextReassert;
/// <summary>매 프레임 — 진행 중인 연쇄를 최대치에 반영하고, 숨김을 주기적으로 다시 건다.</summary>
public static string Tick()
{
if (!s_inStage) return "스테이지 밖 — 무동작";
int cur = KillChain.ChainKills;
if (cur > s_maxChain) s_maxChain = cur;
// 🔴 원본 `NewGameUI.SetUI_byGameMode` 가 모드 전환 때 같은 노드를 다시 켠다 → 주기적 재보장
// (813z 최상단 재보장과 같은 방식 · 값이 이미 맞으면 아무 것도 쓰지 않는다 = GC 0)
var g = St;
if (g == null || !g.hideMenusInStage || g.menuReassertSeconds <= 0f) return "tick ok";
float now = Time.unscaledTime;
if (now < s_nextReassert) return "tick ok";
s_nextReassert = now + g.menuReassertSeconds;
ReassertHide();
return "tick ok";
}
/// <summary>숨긴 목록 중 다시 켜진 것을 도로 끈다(문자열 0 · 할당 0). 켠 횟수를 돌려준다.</summary>
public static int ReassertHide()
{
int n = 0;
for (int i = 0; i < s_hiddenCount; i++)
{
var t = s_hidden[i];
if (t == null) continue;
if (t.gameObject.activeSelf) { t.gameObject.SetActive(false); n++; MenuReassertHits++; }
}
if (n > 0) MenuReassertCalls++;
return n;
}
// ═════════════════════════════════════════════════════════════════════
// 버튼 동작 (815b 공개 API 만 부른다)
// ═════════════════════════════════════════════════════════════════════
/// <summary>[다시 도전] — 같은 스테이지 재도전(무제한 · 기준서 §E).</summary>
public static string OnRetry()
{
RetryCalls++;
ClearFailPopup();
bool ok = WL.Combat.Stage.StageDirector.Retry();
LastLog = "다시 도전 — StageDirector.Retry() = " + ok;
return LastLog;
}
/// <summary>[다음 스테이지] — 마지막 스테이지면 [처음부터](= Enter(0)).</summary>
public static string OnNextStage()
{
var last = WL.Combat.Stage.StageDirector.LastResult;
if (last.isLastStage)
{
RestartCalls++;
bool r = WL.Combat.Stage.StageDirector.Enter(0, "815d 처음부터");
LastLog = "처음부터 — StageDirector.Enter(0) = " + r;
return LastLog;
}
NextCalls++;
bool ok = WL.Combat.Stage.StageDirector.Next();
LastLog = "다음 스테이지 — StageDirector.Next() = " + ok;
return LastLog;
}
/// <summary>[로비로] — `WLStageUiSettings.lobbyMode` 가 정한다(발주서 §1-6 비교 표).</summary>
public static string OnToLobby()
{
LobbyCalls++;
var g = St;
ClearFailPopup();
WL.Combat.Stage.StageDirector.ToLobby(); // 축 종료 + 억제 복원(+ SO 가 켜져 있으면 원본 Return_To_Lobby)
if (g == null) { LastLog = "로비로 — 설정 없음(StageDirector.ToLobby 만)"; return LastLog; }
if (g.lobbyMode == WLStageLobbyMode.TitleScene && Application.isPlaying)
{
try
{
UnityEngine.SceneManagement.SceneManager.LoadScene(g.titleSceneName);
LastLog = "로비로 — 타이틀 씬 \"" + g.titleSceneName + "\" 로드";
}
catch (System.Exception ex) { LastLog = "로비로 — 타이틀 씬 실패(" + ex.GetType().Name + ")"; }
return LastLog;
}
LastLog = "로비로 — " + g.lobbyMode +
" (toLobbyCallsReturnToLobby=" + StageSettingFlagText() + ")";
return LastLog;
}
private static string StageSettingFlagText()
{
var c = WL.Combat.Stage.WLStageSettings.Instance;
return c != null ? (c.toLobbyCallsReturnToLobby ? "1" : "0") : "에셋없음";
}
// ═════════════════════════════════════════════════════════════════════
// 불필요 메뉴 숨김 / 복원 (§J ① · 원본 프리팹 diff 0)
// ═════════════════════════════════════════════════════════════════════
/// <summary>스테이지 중에만 숨긴다. <paramref name="hide"/> false 면 원래 activeSelf 로 되돌린다.</summary>
public static string ApplyMenuHide(bool hide)
{
var g = St;
if (g == null) return "설정 없음";
if (!hide)
{
if (!g.restoreMenusOnExit) return "restoreMenusOnExit=0 — 숨긴 채로 둔다";
int n = 0;
for (int i = 0; i < s_hiddenCount; i++)
{
var t = s_hidden[i];
if (t == null) continue;
if (t.gameObject.activeSelf != s_hiddenPrev[i]) { t.gameObject.SetActive(s_hiddenPrev[i]); n++; }
s_hidden[i] = null;
}
s_hiddenCount = 0;
MenuRestoreCalls++;
return "메뉴 복원 " + n + "건";
}
if (!g.hideMenusInStage) return "hideMenusInStage=0 — 숨기지 않는다";
var root = ResolveUiRoot();
if (root == null) return "NewGameUI 없음 — 숨김 보류(러너가 다시 시도한다)";
var paths = g.stageHidePaths != null ? g.stageHidePaths : new string[0];
if (s_hidden.Length < paths.Length)
{
System.Array.Resize(ref s_hidden, paths.Length);
System.Array.Resize(ref s_hiddenPrev, paths.Length);
}
s_hiddenCount = 0;
MenuMissing = 0;
int hidden = 0;
for (int i = 0; i < paths.Length; i++)
{
if (string.IsNullOrEmpty(paths[i])) continue;
var t = WLVignetteUtil.FindUiPath(root, paths[i]);
if (t == null) { MenuMissing++; LastMissingPath = paths[i]; continue; }
s_hidden[s_hiddenCount] = t;
s_hiddenPrev[s_hiddenCount] = t.gameObject.activeSelf;
s_hiddenCount++;
if (t.gameObject.activeSelf) { t.gameObject.SetActive(false); hidden++; }
}
MenuHideCalls++;
return "메뉴 숨김 — 경로 " + paths.Length + "개 중 찾음 " + s_hiddenCount + " · 실제로 끈 것 " + hidden +
" · 못 찾음 " + MenuMissing + (MenuMissing > 0 ? "(마지막 \"" + LastMissingPath + "\")" : "");
}
/// <summary>NewGameUI 루트(비활성 자식까지 따라가는 경로 탐색의 기준).</summary>
private static Transform ResolveUiRoot()
{
var ui = NewGameUI.Ins;
if (ui != null) { s_uiRoot = ui.transform; return s_uiRoot; }
return s_uiRoot;
}
/// <summary>
/// 프로브 전용 — 피격 1건을 그대로 센다. 🔴 실제 경로(`CombatEvents.Damaged` 의 `victim == MyValue.MyPC`)는
/// 에디트 모드에 PC 가 없어 태울 수 없다(813d 미확인 ②와 같은 한계). 필터가 **거르는 쪽**은 실측 가능하다.
/// </summary>
public static string ProbeNoteHit() { s_hits++; return "피격 " + s_hits + "회"; }
/// <summary>프로브 전용 — 스테이지 안 상태를 만든다(이벤트 없이).</summary>
public static string ProbeSetInStage(bool on) { s_inStage = on; return "inStage=" + s_inStage; }
/// <summary>프로브·에디트 모드용 — 루트를 직접 지정한다.</summary>
public static string SetUiRootForProbe(Transform root)
{
s_uiRoot = root;
return "uiRoot=" + (root != null ? root.name : "null");
}
/// <summary>NewGameUI 가 새로 생기면(맵 재로드) 숨김을 다시 건다 — 러너가 부른다.</summary>
public static string Rebind(Transform uiRoot)
{
s_uiRoot = uiRoot;
if (!s_inStage) return "스테이지 밖 — 다시 걸 것 없음";
return ApplyMenuHide(true);
}
private static void ClearFailPopup()
{
var dlg = ReviveDialog.Active;
if (dlg == null) return;
dlg.ClearStageMode();
}
// ── 유틸 ──────────────────────────────────────────────────────────────
private static readonly StringBuilder s_fmt = new StringBuilder(64);
/// <summary>"{0}" 한 자리 치환(정수 전용 · string.Format 없이).</summary>
private static string FormatOne(string format, long a0)
{
if (string.IsNullOrEmpty(format)) return a0.ToString();
s_fmt.Length = 0;
for (int i = 0; i < format.Length; i++)
{
if (format[i] == '{' && i + 2 < format.Length && format[i + 1] == '0' && format[i + 2] == '}')
{ s_fmt.Append(a0); i += 2; continue; }
s_fmt.Append(format[i]);
}
return s_fmt.ToString();
}
private static void Log(WLStageUiSettings g, string msg)
{
LastLog = msg;
if (g != null && g.verboseLog) Debug.Log("[StageUiBridge] " + msg);
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
public static string ResetState()
{
EnteredSeen = CountdownSeen = StartedSeen = EnemySeen = TimeSeen = ClearedSeen = FailedSeen = ExitedSeen = 0;
MenuHideCalls = MenuRestoreCalls = MenuMissing = 0;
MenuReassertCalls = MenuReassertHits = 0; s_nextReassert = 0f;
RetryCalls = NextCalls = LobbyCalls = RestartCalls = 0;
s_hits = s_maxChain = 0; s_potionsAtStart = PotionUse.UsedCount;
LastLog = ""; LastMissingPath = "";
return "상태 초기화";
}
public static string Teardown()
{
ApplyMenuHide(false);
Subscribe(false);
s_inStage = false;
RunHud.SetStageMode(false);
RunResultUI.SetStageMode(false);
ClearFailPopup();
ResetState();
return "teardown 완료";
}
public static string Dump()
{
var g = St;
var sb = new StringBuilder();
sb.AppendLine("[StageUiBridge] 구독=" + s_subscribed + " inStage=" + s_inStage + " 스테이지=" + s_stageNo +
" · 입장 " + EnteredSeen + " 카운트 " + CountdownSeen + " 시작 " + StartedSeen +
" 적수 " + EnemySeen + " 시간 " + TimeSeen + " 클리어 " + ClearedSeen +
" 실패 " + FailedSeen + " 이탈 " + ExitedSeen);
sb.AppendLine(" 실측 — 피격 " + s_hits + "회 · 물약 " + PotionsUsed + "개(시작 스냅 " + s_potionsAtStart +
" / 현재 " + PotionUse.UsedCount + ") · 최대 연쇄 " + s_maxChain +
"(진행중 " + KillChain.ChainKills + ")");
sb.AppendLine(" 메뉴 — 숨김 " + MenuHideCalls + "회 / 복원 " + MenuRestoreCalls + "회 / 재보장 " +
MenuReassertCalls + "회(도로 끈 것 " + MenuReassertHits + "건 · 주기 " +
(g != null ? g.menuReassertSeconds.ToString("F2") : "-") + "s) · 지금 숨긴 것 " +
s_hiddenCount + "건 · 못 찾은 경로 " + MenuMissing +
(string.IsNullOrEmpty(LastMissingPath) ? "" : "(\"" + LastMissingPath + "\")"));
for (int i = 0; i < s_hiddenCount; i++)
{
var t = s_hidden[i];
sb.AppendLine(" - " + (t == null ? "(파괴됨)" : t.name + " activeSelf=" + t.gameObject.activeSelf +
" 원래=" + s_hiddenPrev[i]));
}
sb.AppendLine(" 버튼 — 다시도전 " + RetryCalls + " 다음 " + NextCalls + " 처음부터 " + RestartCalls +
" 로비 " + LobbyCalls + " · 로비모드=" + (g != null ? g.lobbyMode.ToString() : "-") +
" · toLobbyCallsReturnToLobby=" + StageSettingFlagText());
sb.AppendLine(" 설정 enabled=" + (g != null ? g.enabled_.ToString() : "에셋없음") +
" hud=" + (g != null ? g.stageHudEnabled.ToString() : "-") +
" 메뉴숨김=" + (g != null ? g.hideMenusInStage.ToString() : "-") +
" 결과=" + (g != null ? g.stageResultEnabled.ToString() : "-") +
" 실패팝업=" + (g != null ? g.stageFailEnabled.ToString() : "-") +
" · 마지막 로그 = " + LastLog);
return sb.ToString();
}
}
/// <summary>
/// 🔴 **검증 전용** — 에디트 모드 프로브가 815b `StageEvents` 의 **실제 Dispatch 경로**로
/// 가짜 이벤트를 흘려 넣는 진입점(813q `WLRunUiProbeHooks` 선례).
/// `CombatEventList&lt;T&gt;.Dispatch` 는 `internal` 이라 Assets 안(같은 Assembly-CSharp)에서만 부를 수 있다.
/// 전부 **void** 다 — 반환 문자열을 만들면 GC 측정이 오염된다. 게임 코드는 한 번도 부르지 않는다.
/// </summary>
public static class WLStageUiProbeHooks
{
public static void RaiseEntered(int index, int stageId, int total, float timeLimit, Vector3 anchor,
float radius, float countdown, int retries)
{
var e = new WL.Combat.Stage.StageEnteredEvent
{
index = index, stageId = stageId, def = null, totalEnemies = total, timeLimit = timeLimit,
anchor = anchor, barrierRadius = radius, countdownSeconds = countdown, retries = retries,
time = Time.unscaledTime, frame = Time.frameCount,
};
WL.Combat.Stage.StageEvents.StageEntered.Dispatch(in e);
}
public static void RaiseCountdown(int index, float remaining, float total)
{
var e = new WL.Combat.Stage.StageCountdownEvent
{ index = index, remaining = remaining, total = total, time = Time.unscaledTime, frame = Time.frameCount };
WL.Combat.Stage.StageEvents.CountdownTick.Dispatch(in e);
}
public static void RaiseStarted(int index, int stageId, int total, float timeLimit, int retries)
{
var e = new WL.Combat.Stage.StageStartedEvent
{
index = index, stageId = stageId, totalEnemies = total, timeLimit = timeLimit, retries = retries,
time = Time.unscaledTime, frame = Time.frameCount,
};
WL.Combat.Stage.StageEvents.StageStarted.Dispatch(in e);
}
public static void RaiseEnemyCount(int index, int remaining, int total)
{
var e = new WL.Combat.Stage.StageEnemyCountEvent
{ index = index, remaining = remaining, total = total, time = Time.unscaledTime, frame = Time.frameCount };
WL.Combat.Stage.StageEvents.EnemyCountChanged.Dispatch(in e);
}
public static void RaiseTime(int index, float remaining, float limit)
{
var e = new WL.Combat.Stage.StageTimeEvent
{ index = index, remaining = remaining, limit = limit, time = Time.unscaledTime, frame = Time.frameCount };
WL.Combat.Stage.StageEvents.TimeChanged.Dispatch(in e);
}
public static void RaiseCleared(int index, int stageId, float elapsed, float limit, int killed, int total,
int retries, bool isLast, bool isBoss)
{
var e = new WL.Combat.Stage.StageClearResult
{
index = index, stageId = stageId, win = true, failReason = WL.Combat.Stage.StageFailReason.None,
elapsedSec = elapsed, timeLimit = limit, killed = killed, total = total, remaining = total - killed,
retries = retries, isLastStage = isLast, isBoss = isBoss,
time = Time.unscaledTime, frame = Time.frameCount,
};
WL.Combat.Stage.StageEvents.StageCleared.Dispatch(in e);
}
public static void RaiseFailed(int index, int stageId, WL.Combat.Stage.StageFailReason reason,
int remaining, int total, float elapsed, int retries)
{
var e = new WL.Combat.Stage.StageFailedEvent
{
index = index, stageId = stageId, reason = reason, remaining = remaining, total = total,
elapsedSec = elapsed, retries = retries, time = Time.unscaledTime, frame = Time.frameCount,
};
WL.Combat.Stage.StageEvents.StageFailed.Dispatch(in e);
}
public static void RaiseExited(int index, bool toLobby, string reason)
{
var e = new WL.Combat.Stage.StageExitedEvent
{ index = index, toLobby = toLobby, reason = reason, time = Time.unscaledTime, frame = Time.frameCount };
WL.Combat.Stage.StageEvents.StageExited.Dispatch(in e);
}
/// <summary>피격 집계 검증용(같은 Dispatch 경로 · victim = 실제 PC 가 있어야 세어진다).</summary>
public static void RaiseDamaged(Actor victim)
{
var e = new DamagedEvent { victim = victim, attacker = null, damage = 1.0, invincible = false,
time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Damaged.Dispatch(in e);
}
/// <summary>최대 연쇄 검증용(811e KillChain 의 실제 Dispatch 경로).</summary>
public static void RaiseChainEnded(int kills, int bestTier, float duration)
{
var e = new KillChainEndedEvent { kills = kills, bestTier = bestTier, duration = duration,
time = Time.unscaledTime, frame = Time.frameCount };
KillChain.Ended.Dispatch(in e);
}
}
/// <summary>스테이지 UI 이음매의 시간 축(숨김 GameObject 1개 · 씬·프리팹에 굽지 않는다).</summary>
internal sealed class StageUiRunner : MonoBehaviour
{
private static StageUiRunner s_ins;
private NewGameUI _lastUi;
internal static void Ensure()
{
if (s_ins != null) return;
var go = new GameObject("WL_StageUiRunner");
go.hideFlags = HideFlags.HideAndDontSave;
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<StageUiRunner>();
}
internal static bool Exists { get { return s_ins != null; } }
private void Update()
{
var ui = NewGameUI.Ins;
if (ui != null && !ReferenceEquals(ui, _lastUi))
{
_lastUi = ui;
StageUiBridge.Rebind(ui.transform); // 맵 재로드 뒤에도 숨김을 다시 건다
}
StageUiBridge.Tick();
}
private void OnDestroy() { if (ReferenceEquals(s_ins, this)) s_ins = null; }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d815d01a1b2c3d4e5f60708090a0c04

View File

@ -0,0 +1,99 @@
%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: 8d815d01a1b2c3d4e5f60708090a0c01, type: 3}
m_Name: WLStageUiSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLStageUiSettings
enabled_: 1
verboseLog: 0
stageHudEnabled: 1
hideZoneGaugeInStage: 1
stageLabelFormat: "\uC2A4\uD14C\uC774\uC9C0 {0}"
enemyLabelFormat: "\uB0A8\uC740 \uC801 {0} / {1}"
enemyFontPx: 38
enemyTopGapPx: 6
enemyColor: {r: 0.9, g: 0.91, b: 0.94, a: 1}
enemyHighlightAtOrBelow: 3
enemyHighlightColor: {r: 0.99, g: 0.78, b: 0.3, a: 1}
timerWarnSeconds: 10
timerWarnColor: {r: 1, g: 0.29, b: 0.278, a: 1}
countdownEnabled: 1
countdownText: "\uC900\uBE44"
countdownNumberFormat: "{0}"
countdownFontPx: 72
countdownColor: {r: 1, g: 0.86, b: 0.42, a: 1}
countdownTopGapPx: 24
hideMenusInStage: 1
stageHidePaths:
- Common/GuideQuest
- Common/TopRightBtns
- Common/TopRightMoney
- Common/Lv
- IngameUIs/WL_HUD/HUD_Currency
- IngameUIs/WL_HUD/HUD_Level
- IngameUIs/WL_HUD/HUD_BottomMenu
- IngameUIs/Stage/exit
- IngameUIs/Stage/btn_setting
- IngameUIs/MonsterSimpleInfo
- IngameUIs/IngamePetCard
stageKeepPaths:
- IngameUIs/BattleUI
- Common/MyInfoUI
- IngameUIs/WL_HUD/WL_BossHpBar
- IngameUIs/WL_HUD/WL_KillChainText
- IngameUIs/WL_HUD/WL_LootToast
- IngameUIs/WL_HUD/WL_HitVignette
- IngameUIs/WL_HUD/WL_ReviveDialog
keepLookToggleButtons: 1
restoreMenusOnExit: 1
menuReassertSeconds: 0.5
stageResultEnabled: 1
stageClearTitleFormat: "\uC2A4\uD14C\uC774\uC9C0 {0} \uD074\uB9AC\uC5B4!"
stageAllClearTitleFormat: "\uC804 \uC2A4\uD14C\uC774\uC9C0 \uD074\uB9AC\uC5B4!"
stageResultRows:
- 0
- 1
- 2
- 3
- 4
- 5
rowClearTimeLabel: "\uD074\uB9AC\uC5B4 \uC2DC\uAC04"
rowKillsLabel: "\uCC98\uCE58"
rowEliteLabel: "\uC5D8\uB9AC\uD2B8 \uCC98\uCE58"
rowMaxChainLabel: "\uCD5C\uB300 \uC5F0\uC1C4"
rowHitsPotionsLabel: "\uD53C\uACA9 / \uBB3C\uC57D"
rowRetriesLabel: "\uC7AC\uB3C4\uC804"
rowBossLabel: "\uBCF4\uC2A4 \uCC98\uCE58"
rowLevelLabel: "\uB808\uBCA8"
rowGoldLabel: "\uACE8\uB4DC"
rowLootLabel: "\uD68D\uB4DD"
rowKillsFormat: "{0} / {1}"
rowHitsPotionsFormat: "{0}\uD68C / {1}\uAC1C"
rowRetriesFormat: "{0}\uD68C"
rowBossClearedText: "\uCC98\uCE58"
rowUnknownText: "\u2014"
noHitBadgeEnabled: 1
noHitBadgeText: "\uBB34\uD53C\uACA9 \uD074\uB9AC\uC5B4!"
noHitBadgeFontPx: 40
noHitBadgeColor: {r: 0.137, g: 0.906, b: 0.529, a: 1}
nextStageText: "\uB2E4\uC74C \uC2A4\uD14C\uC774\uC9C0"
restartFromFirstText: "\uCC98\uC74C\uBD80\uD130"
showRestartOnLastStage: 1
toLobbyText: "\uB85C\uBE44\uB85C"
stageFailEnabled: 1
failDeathMessage: "\uC4F0\uB7EC\uC84C\uC2B5\uB2C8\uB2E4.\n\uB2E4\uC2DC \uB3C4\uC804\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C?"
failTimeoutMessage: "\uC2DC\uAC04 \uCD08\uACFC!\n\uB0A8\uC740 \uC801 {0}\uB9C8\uB9AC"
failAbortedMessage: "\uC2A4\uD14C\uC774\uC9C0\uAC00 \uC911\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4."
failRetryText: "\uB2E4\uC2DC \uB3C4\uC804"
failLobbyText: "\uB85C\uBE44\uB85C"
suppressResultOnFail: 1
lobbyMode: 0
titleSceneName: Title

View File

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

View File

@ -0,0 +1,337 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLStageUiSettings.cs — 스테이지 UI 값의 단일 출처 (WL-815d · #815)
//
// PD 지시 #815 + 추가 지시(2026-09-11 00:2x) · 발주서 WL-815d §1-3~§1-6.
// SOT = 기준서 `스테이지_전환_기준서_v1.md` §E(실패·재도전) · §F-1(클리어 화면 행) · §F-2(전투 HUD)
// + 발주서 WL-815a §3(§J 인게임 HUD·조작 규칙 원문).
//
// ■ 왜 별도 에셋인가 (C45 · C8)
// 813q `WLRunUiSettings`(런 UI)와 **수명이 다르다** — 이 에셋을 `enabled = 0` 으로 두면
// 스테이지 모드 표시·메뉴 숨김·스테이지 결과/실패 화면이 통째로 사라지고
// 813q/813z 가 **원래대로**(런 HUD · 부활 팝업 1버튼) 돈다. 813q 축은 건드리지 않는다.
//
// ■ 좌표계 = 813c/813q 와 같은 **세로 1080×1920 화면 px**.
// px → 캔버스 유닛 환산은 `WLHudLayoutSettings.UnitsPerPx(canvas)` 하나만 쓴다(산식 중복 0).
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.UI
{
/// <summary>스테이지 클리어 화면 본문 행 — 배열 순서 = 표시 순서 · 배열에 없으면 표시 안 함(기준서 §F-1).</summary>
public enum WLStageResultRow
{
/// <summary>클리어 시간 mm:ss (`StageClearResult.elapsedSec`).</summary>
ClearTime = 0,
/// <summary>처치 N / N (`killed` / `total`).</summary>
Kills = 1,
/// <summary>엘리트 처치(UI 자체 집계 · 813q2 와 같은 경로).</summary>
EliteKills = 2,
/// <summary>최대 연쇄(811e `KillChain.Ended` 구독 · 스테이지 구간 최대).</summary>
MaxChain = 3,
/// <summary>피격 N회 / 물약 N개(피격 = UI 자체 집계 · 물약 = 813i `PotionUse` 잔량 차).</summary>
HitsPotions = 4,
/// <summary>재도전 N회 (`StageClearResult.retries`).</summary>
Retries = 5,
/// <summary>보스 처치(보스 스테이지에서만 · `isBoss`).</summary>
BossClear = 6,
/// <summary>🔴 레벨 from → to — **미확인**(815b `MirrorRunEnded` 페이로드에 값이 없다). 배열에 넣으면 대시로 나온다.</summary>
Level = 7,
/// <summary>🔴 획득 골드 — **미확인**(같은 이유).</summary>
Gold = 8,
/// <summary>🔴 등급별 획득 — **미확인**(같은 이유).</summary>
LootByGrade = 9,
}
/// <summary>「로비로」가 실제로 하는 일(발주서 §1-6 · 실측 비교 표는 완료보고 참조).</summary>
public enum WLStageLobbyMode
{
/// <summary>ⓐ 원본 `InGameInfo.Return_To_Lobby()` — 필드(WL_Nature) 재로드. 로그인 유지 · 코드 0줄.</summary>
ReturnToLobby = 0,
/// <summary>ⓑ 타이틀 씬 복귀 — `SceneManager.LoadScene(titleSceneName)`. 로그인부터 다시.</summary>
TitleScene = 1,
/// <summary>ⓒ 아무 것도 하지 않는다(스테이지 축만 종료 · 화면 그대로). 진단·A/B 전용.</summary>
StayInPlace = 2,
}
[CreateAssetMenu(fileName = "WLStageUiSettings", menuName = "WL/Stage UI Settings", order = 34)]
public sealed class WLStageUiSettings : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLStageUiSettings";
private static WLStageUiSettings s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 전부 건너뛴다 = C8 롤백).</summary>
public static WLStageUiSettings Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLStageUiSettings>(ResourcesPath);
}
return s_instance;
}
}
/// <summary>에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다.</summary>
public static void ClearCache() { s_instance = null; s_tried = false; }
/// <summary>프로브·QA 용 런타임 스위치(에셋을 건드리지 않고 끈다 · 도메인 리로드로 자동 false).</summary>
public static bool RuntimeDisabled;
/// <summary>스테이지 UI 축이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
public static bool Enabled
{
get
{
if (RuntimeDisabled) return false;
var s = Instance;
return s != null && s.enabled_;
}
}
// ── 공통 ──────────────────────────────────────────────────────────────
[Header("공통 (C8 롤백 1순위)")]
[Tooltip("끄면 스테이지 모드 HUD·메뉴 숨김·스테이지 결과/실패 화면이 전부 무동작 — 813q/813z 가 원래대로 돈다.")]
public bool enabled_ = true;
[Tooltip("상태 전이마다 Console 에 1줄 남긴다(문자열을 만들기 전에 이 값으로 막는다 · GC 주의).")]
public bool verboseLog = false;
// ── 상단 3요소 (PD: 스테이지 정보 · 남은 시간 · 처치할 적 수 「만」) ──
[Header("상단 HUD — 3요소만 (§J ②)")]
[Tooltip("스테이지 중에는 813q 런 HUD 를 스테이지 모드로 바꾼다. 끄면 813q 원래 표시(존 게이지·단계 라벨).")]
public bool stageHudEnabled = true;
[Tooltip("스테이지 중 존 게이지 칸을 숨긴다(기준서 §F-2 「남은 적 숫자로」).")]
public bool hideZoneGaugeInStage = true;
[Tooltip("단계 라벨 문구 — {0} = 스테이지 번호(1부터).")]
public string stageLabelFormat = "스테이지 {0}";
[Tooltip("남은 적 문구 — {0} = 남은 수, {1} = 고정 수.")]
public string enemyLabelFormat = "남은 적 {0} / {1}";
[Tooltip("남은 적 줄 글자 크기(px).")]
public float enemyFontPx = 38f;
[Tooltip("단계 라벨 아래 남은 적 줄까지 간격(px).")]
public float enemyTopGapPx = 6f;
[Tooltip("남은 적 줄 평상시 색.")]
public Color enemyColor = new Color(0.90f, 0.91f, 0.94f, 1f);
[Tooltip("남은 적이 이 수 이하로 내려가면 강조 색으로 바뀐다(0 = 강조 없음).")]
public int enemyHighlightAtOrBelow = 3;
[Tooltip("남은 적 강조 색.")]
public Color enemyHighlightColor = new Color(0.99f, 0.78f, 0.30f, 1f);
[Tooltip("🔴 스테이지 타이머 경고 초 — PD 「60 초」 규칙에 맞춰 813q 의 30 s 대신 마지막 10 s 를 붉게. " +
"813q `WLRunUiSettings.timerWarnSeconds`(30)는 런 HUD 용으로 그대로 둔다.")]
public float timerWarnSeconds = 10f;
[Tooltip("스테이지 타이머 경고 색(마지막 10 s).")]
public Color timerWarnColor = new Color(1f, 0.290f, 0.278f, 1f);
// ── 카운트다운 ────────────────────────────────────────────────────────
[Header("입장 카운트다운 (기준서 §B-2 · countdownSeconds 1.5)")]
[Tooltip("입장 카운트다운을 화면에 표시한다.")]
public bool countdownEnabled = true;
[Tooltip("🔴 「3·2·1」 대신 쓰는 고정 문구(발주서 「제안」). 비우면 남은 초를 숫자로 띄운다.")]
public string countdownText = "준비";
[Tooltip("countdownText 가 비었을 때 쓰는 숫자 문구 — {0} = 올림한 남은 초.")]
public string countdownNumberFormat = "{0}";
[Tooltip("카운트다운 글자 크기(px).")]
public float countdownFontPx = 72f;
[Tooltip("카운트다운 색.")]
public Color countdownColor = new Color(1f, 0.86f, 0.42f, 1f);
[Tooltip("카운트다운 줄을 상단 3요소 아래 몇 px 에 둘지(HUD 루트 기준 · 아래 방향).")]
public float countdownTopGapPx = 24f;
// ── 불필요 메뉴 숨김 (§J ①) ──────────────────────────────────────────
[Header("불필요 메뉴 숨김 (§J ① · 이탈 시 자동 복원 · 원본 프리팹 무수정)")]
[Tooltip("스테이지 중에만 아래 경로를 SetActive(false) 한다. 이탈(로비·중단·축 off)하면 원래 값으로 되돌린다.")]
public bool hideMenusInStage = true;
[Tooltip("🔴 숨길 노드 경로(NewGameUI 루트 기준 · 실측 목록은 완료보고 표). " +
"813tj 가 Safe Area 패널을 끼워 넣었으므로 경로 탐색은 WLVignetteUtil.FindUiPath 가 한 겹 건너뛴다.")]
public string[] stageHidePaths = new string[]
{
// 원본 공용 UI(Common) — 인게임에서도 남아 있는 상단 메뉴들
"Common/GuideQuest", // 가이드 퀘스트 버튼(상단 좌)
"Common/TopRightBtns", // 뽑기 · 상점 · 우편 버튼 묶음(상단 우)
"Common/TopRightMoney", // 원본 상단 재화 바(#775 가 인게임에서 이미 끄지만 2중 보장)
"Common/Lv", // 원본 경험치 바 + 레벨(같은 이유)
// WL HUD 안의 재화·레벨·하단 메뉴(813 이 만든 것 — 스테이지에서는 3요소만)
"IngameUIs/WL_HUD/HUD_Currency",
"IngameUIs/WL_HUD/HUD_Level",
"IngameUIs/WL_HUD/HUD_BottomMenu",
// 원본 인게임(Stage) 화면의 나가기·설정 — 「로비로」는 결과/실패 화면이 준다
"IngameUIs/Stage/exit",
"IngameUIs/Stage/btn_setting",
// 원본 타깃 몬스터 정보 바(813c3 보스 HP 바가 대체) · 펫 카드(펫 스폰 금지 규약)
"IngameUIs/MonsterSimpleInfo",
"IngameUIs/IngamePetCard",
};
[Tooltip("🔴 유지 목록(끄지 않는 것) — 이 배열은 **검증·문서용**이다(코드가 켜지도 않는다). " +
"조이스틱·스킬 패드(BattleUI) · HP/MP(MyInfoUI) · 물약 버튼 · 814d 룩 토글 · 813 WL HUD 요소.")]
public string[] stageKeepPaths = new string[]
{
"IngameUIs/BattleUI", // 조이스틱 · 공격 · 회피 · 스킬 카드 6
"Common/MyInfoUI", // HP / MP 슬라이더
"IngameUIs/WL_HUD/WL_BossHpBar",
"IngameUIs/WL_HUD/WL_KillChainText",
"IngameUIs/WL_HUD/WL_LootToast",
"IngameUIs/WL_HUD/WL_HitVignette",
"IngameUIs/WL_HUD/WL_ReviveDialog",
};
[Tooltip("814d 룩 토글 버튼([카메라: N] · [: ON/OFF]) ( §1-3).")]
public bool keepLookToggleButtons = true;
[Tooltip("스테이지를 나갈 때 숨긴 노드를 원래 activeSelf 로 되돌린다(끄면 숨긴 채로 둔다 — 진단 전용).")]
public bool restoreMenusOnExit = true;
[Tooltip("🔴 스테이지 중 이 주기(초 · unscaled)마다 숨김을 다시 건다. 원본 `NewGameUI.SetUI_byGameMode` 가 " +
"모드 전환 때 같은 노드를 다시 켜기 때문(813z 최상단 재보장과 같은 방식 · 값이 이미 맞으면 아무 것도 쓰지 않는다 = GC 0). " +
"0 이면 재보장 없음(입장 1회만).")]
public float menuReassertSeconds = 0.5f;
// ── 클리어 화면 (기준서 §F-1) ────────────────────────────────────────
[Header("클리어 화면 (기준서 §F-1 · 813q RunResultUI 재사용)")]
[Tooltip("끄면 스테이지 클리어에 813q 결과창이 **런 모드 그대로** 뜬다(제목 「런 클리어!」 · [다음 런]).")]
public bool stageResultEnabled = true;
[Tooltip("클리어 제목 — {0} = 스테이지 번호.")]
public string stageClearTitleFormat = "스테이지 {0} 클리어!";
[Tooltip("마지막 스테이지 클리어 제목 — {0} = 스테이지 번호.")]
public string stageAllClearTitleFormat = "전 스테이지 클리어!";
[Tooltip("본문 행 순서(배열에서 빼면 표시 안 함). 기준서 §F-1 의 「남김」 행만 기본으로 둔다.")]
public WLStageResultRow[] stageResultRows = new WLStageResultRow[]
{
WLStageResultRow.ClearTime,
WLStageResultRow.Kills,
WLStageResultRow.EliteKills,
WLStageResultRow.MaxChain,
WLStageResultRow.HitsPotions,
WLStageResultRow.Retries,
};
public string rowClearTimeLabel = "클리어 시간";
public string rowKillsLabel = "처치";
public string rowEliteLabel = "엘리트 처치";
public string rowMaxChainLabel = "최대 연쇄";
public string rowHitsPotionsLabel = "피격 / 물약";
public string rowRetriesLabel = "재도전";
public string rowBossLabel = "보스 처치";
public string rowLevelLabel = "레벨";
public string rowGoldLabel = "골드";
public string rowLootLabel = "획득";
[Tooltip("처치 행 값 문구 — {0} = 처치 수, {1} = 고정 수.")]
public string rowKillsFormat = "{0} / {1}";
[Tooltip("피격/물약 행 값 문구 — {0} = 피격 횟수, {1} = 물약 사용 수.")]
public string rowHitsPotionsFormat = "{0}회 / {1}개";
[Tooltip("재도전 행 값 문구 — {0} = 재도전 횟수.")]
public string rowRetriesFormat = "{0}회";
[Tooltip("보스 처치 행 값(보스 스테이지 클리어).")]
public string rowBossClearedText = "처치";
[Tooltip("값을 모를 때 쓰는 문구(레벨·골드·등급별 획득 = 미확인).")]
public string rowUnknownText = "—";
[Header("클리어 화면 — 무피격 배지 (기준서 §F-1 「제안」)")]
[Tooltip("피격 0회로 클리어하면 제목 아래에 배지를 띄운다.")]
public bool noHitBadgeEnabled = true;
[Tooltip("배지 문구.")]
public string noHitBadgeText = "무피격 클리어!";
[Tooltip("배지 글자 크기(px).")]
public float noHitBadgeFontPx = 40f;
[Tooltip("배지 색.")]
public Color noHitBadgeColor = new Color(0.137f, 0.906f, 0.529f, 1f);
[Header("클리어 화면 — 버튼")]
[Tooltip("왼쪽 버튼 문구 — 누르면 StageDirector.Next().")]
public string nextStageText = "다음 스테이지";
[Tooltip("마지막 스테이지에서 왼쪽 버튼 문구 — 누르면 StageDirector.Enter(0)(「제안」).")]
public string restartFromFirstText = "처음부터";
[Tooltip("마지막 스테이지 클리어에서도 왼쪽 버튼을 띄운다(끄면 [로비로] ).")]
public bool showRestartOnLastStage = true;
[Tooltip("오른쪽 버튼 문구 — 누르면 「로비로」(lobbyMode).")]
public string toLobbyText = "로비로";
// ── 실패 팝업 (기준서 §E) ────────────────────────────────────────────
[Header("실패 팝업 (기준서 §E · 813z ReviveDialog 2버튼)")]
[Tooltip("끄면 스테이지 실패에 813z 부활 팝업이 **원래대로**(1버튼 「부활」) 뜬다.")]
public bool stageFailEnabled = true;
[Tooltip("사망 실패 본문.")]
public string failDeathMessage = "쓰러졌습니다.\n다시 도전하시겠습니까?";
[Tooltip("시간 초과 실패 본문 — {0} = 남은 적 수.")]
public string failTimeoutMessage = "시간 초과!\n남은 적 {0}마리";
[Tooltip("중단(맵 이탈 등) 본문 — 보통 뜨지 않는다.")]
public string failAbortedMessage = "스테이지가 중단되었습니다.";
[Tooltip("실패 팝업 왼쪽 버튼 문구 — 누르면 StageDirector.Retry().")]
public string failRetryText = "다시 도전";
[Tooltip("실패 팝업 오른쪽 버튼 문구 — 누르면 「로비로」(lobbyMode).")]
public string failLobbyText = "로비로";
[Tooltip("🔴 실패에는 스테이지 결과 화면을 띄우지 않는다(815b `mirrorRunEnded` 가 발행하는 RunEnded(Timeout) 로 " +
"813q 결과창이 겹쳐 뜨는 것을 막는다 — 결과창 중복 표시 0).")]
public bool suppressResultOnFail = true;
// ── 로비 (발주서 §1-6) ───────────────────────────────────────────────
[Header("로비 = 최소 공수 (발주서 §1-6 · 비교 표는 완료보고 §로비)")]
[Tooltip("🔴 「로비로」가 하는 일. 기본 ⓐ ReturnToLobby = 원본 InGameInfo.Return_To_Lobby()" +
"(= 필드 WL_Nature 재로드 → autoEnterOnMapEnter 로 스테이지 1 자동 입장 = 사실상 「처음부터」). " +
"`WLStageSettings.toLobbyCallsReturnToLobby` 가 실제 호출을 쥐고 있고 이 값은 UI 쪽 분기다.")]
public WLStageLobbyMode lobbyMode = WLStageLobbyMode.ReturnToLobby;
[Tooltip("lobbyMode = TitleScene 일 때 불러올 씬 이름(EditorBuildSettings 실측 = Assets/Scenes/Title.unity).")]
public string titleSceneName = "Title";
// ── 파생 헬퍼 (코드 상수 0) ───────────────────────────────────────────
/// <summary>px → 캔버스 유닛 계수. 813c 의 산식 하나만 쓴다(중복 0).</summary>
public float UnitsPerPx(Canvas canvas)
{
var hud = WLHudLayoutSettings.Instance;
return hud != null ? hud.UnitsPerPx(canvas) : 1f;
}
/// <summary>그 행을 표시하는가(배열에 있으면 표시).</summary>
public bool HasRow(WLStageResultRow row)
{
if (stageResultRows == null) return false;
for (int i = 0; i < stageResultRows.Length; i++) if (stageResultRows[i] == row) return true;
return false;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d815d01a1b2c3d4e5f60708090a0c01