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