diff --git a/AgentScripts/WL813o_PROBE.txt b/AgentScripts/WL813o_PROBE.txt new file mode 100644 index 000000000..3893f87c2 --- /dev/null +++ b/AgentScripts/WL813o_PROBE.txt @@ -0,0 +1,160 @@ +# WL813o Probe 2026-09-09 13:01:57 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 에셋 (WLZonePickupSettings · NEW) · 813p 계약 + [PASS] Resources.Load("WL/WLZonePickupSettings") + [PASS] Enabled = True + 값: cardCount=3 timeout=10s(unscaled=True) first=True includeFinalZone=False guideWait=True(max 8s) deferRevive=True pauseTimer=True holdWorld=False stopActors=True openDelay=0.35s + [PASS] 존4(마지막 존) 기본 제외 — 보스 흐름 방해 금지(발주서 §1-1) + [PASS] 세계 정지 기본 off = 타이머만 정지(발주서 §1-3 권장) + [PASS] 타임아웃 10 s(발주서 §1-2) — 실측 10 + [PASS] 3장(기획안 F-3) — 실측 3 + [PASS] 남은 초·카드 글자 아웃라인(813y ApplyEdge) outlineWidth=0.26 + [PASS] 813p WLRunSettings 존재(존 수·런 길이의 주인 · UI 는 수치를 갖지 않는다) + 813p 값(읽기만): runSeconds=240 존 4개 + +── ① 🔴 원본 SelectSkillUI 노드 실측 — 발주서 §1-1 전제와 다르다 + [PASS] LoadPrefabContents(Assets/Res_Addr/MainUI/NewGameUI.prefab) + NewGameUI.prefab 안: SelectSkillUI=0 SelectSkillCard=0 GameUI=0 UILabel=0 UISprite=0 TextMeshProUGUI=712 + [PASS] 원본 SelectSkillUI 노드 0개 = 열 대상이 없다 + [PASS] GameUI 컴포넌트 0개 · GameUI.Ins=null → MyActor.cs:162 의 자동전투 정지 배선은 작동하지 않는다 + [PASS] 실제 인게임 UI 에 NGUI 위젯 0개(원본 카드 UI 는 NGUI) · TMP 는 712개 = uGUI 트리 + → 그래서 813o 는 ① 원본 노드가 있으면 그대로 열고, ② 없으면 같은 원본 데이터·효과 경로를 쓰는 런타임 uGUI 패널로 간다. + +── ② 원본 데이터·효과 경로 — table_selectskill / InGameInfo / ActorInfo + [PASS] 테이블 원본 TextAsset Assets/ResWork/Table/table_selectskill.txt + [PASS] 원본 table_selectskill.Awake 확보 + [PASS] table_selectskill.Ins 설정(원본 Awake 경로) + [PASS] InGameInfo.Ins 설정 + [PASS] ActorInfo.Ins 설정(All_Stop 대상 0 = 무해) + 테이블 행 수 = 58 + [PASS] 카드 3장을 뽑기에 충분한 행 수 + [PASS] 원본 뽑기 Get_DataList(3) = 3행(셔플·보유 제외·PreSkill 규칙 그대로) + 뽑기 예시 0: id=102 "체력(소)" pet=False + 뽑기 예시 1: id=12 "서리 요정" pet=False + 뽑기 예시 2: id=15 "바람의 길" pet=False + +── ③ 노드 조립 — WL_ZonePickup(런타임 생성 · HideFlags.DontSave) · 구독 3종 + Subscribe : 구독=True (ZoneCleared/RunStarted/RunEnded) + [PASS] ZoneCleared 구독 +1 — 실측 1 + Subscribe(중복): 구독 변화 없음(True) + [PASS] 2회 호출해도 +1(중복 구독 0) + [PASS] IngameUIs/WL_HUD 경로 실측(813y/813q 와 같은 부모) + [PASS] WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안 + Bind : 연결 IngameUIs/WL_HUD · box(1742.22, 1280.00) 카드=3 unitsPerPx=1.7778 + [PASS] WL_ZonePickup 노드 생성 + 배치 : box(1742.22, 1280.00) 카드=3 unitsPerPx=1.7778 + [PASS] 조립 직후에는 닫혀 있다 + +── ④ 가짜 ZoneCleared(존 0) → 카드 3장 열림 + 런 시작: phase=Zones zoneCount=4 + [PASS] ZoneCleared 수신 1 — 실측 1 + [PASS] 예약 상태 = Pending + [PASS] 예약 존 = 0 + Tick(지연 전): 지연 대기 + [PASS] openDelaySeconds 전에는 열리지 않는다 + Tick(지연 후): 열림 — 런타임 패널 + [PASS] 카드 열림 — phase=Open open=True zone=0 picks=0 cards=3 sec=10 timerPaused=True worldHeld=False actorsStopped=True orig=False bound=True + [PASS] 원본 노드가 없어 런타임 패널 경로(폴백)로 열렸다 + [PASS] 카드 3장 — 실측 3 + 카드 0: id=12 "서리 요정" + 카드 1: id=17 "히키코모리 요정" + 카드 2: id=106 "치명타 확률(소)" + [PASS] 3장 전부 실제 스킬 ID + [PASS] 중복 없음 + [PASS] 카드 이름이 테이블 값으로 채워졌다 + [PASS] 제목 = "스킬 선택" + [PASS] 남은 초 첫 표시 = "10초 후 자동 선택" + [PASS] 원본 ActorInfo.All_Stop(true) 도달 = 자동전투 정지 + [PASS] 런 타이머 정지 요청 on(RunDirector.PauseTimer) — 세계 정지 없음 + [PASS] timeScale 무접촉 = 1.00(holdWorld off) + +── ⑤ 타임아웃 → 첫 카드 자동 선택 · 닫힘 · 정지 해제 + 6.8s 남음 표시 = "7초 후 자동 선택" (sec=7) + [PASS] 올림 표기 6.8 → 7 — 실측 7 + 1.5s 남음 표시 = "2초 후 자동 선택" (경고색 구간) + [PASS] 올림 표기 1.5 → 2 — 실측 2 + [PASS] 표시 쓰기 발생 2회 + Tick(타임아웃): 선택 0 id=12 (자동) + [PASS] 타임아웃 1회 — 실측 1 + [PASS] 선택 1회 — 실측 1 + [PASS] 닫힘 — phase=Idle open=False zone=-1 picks=1 cards=3 sec=-2147483648 timerPaused=False worldHeld=False actorsStopped=False orig=False bound=True + [PASS] 원본 InGameInfo.Add_SelectSkill(12) 도달 = 첫 카드가 적용됐다(효과는 원본 로직 그대로) + [PASS] 원본 All_Stop(false) 도달 = 자동전투 복귀 + [PASS] 런 타이머 정지 해제 + [PASS] 이번 런의 픽업 수 = 1 + +── ⑥ 카드 버튼 클릭 경로(자동이 아닌 사용자 선택) + [PASS] 존 1 클리어로 다시 열림 + 클릭 카드 1: id=13 "바람 요정" + ClickCard(1): 클릭 1 + [PASS] 클릭 즉시 닫힘 + [PASS] 타임아웃 아님(클릭 선택) — 실측 0 + [PASS] 클릭한 카드의 효과가 원본 경로로 적용됐다 + +── ⑦ 마지막 존(존4) 제외 · SO 로 포함 전환 + 존 수 = 4 (마지막 인덱스 3) + [PASS] 마지막 존은 카드를 띄우지 않는다 — 마지막 존 제외 + [PASS] 건너뜀 1회 — 실측 1 + [PASS] includeFinalZone=1 이면 마지막 존도 예약된다(코드 변경 0) + +── ⑧ 813i/813z 부활 팝업과 동시 — 부활 우선(카드는 미룬다) + [PASS] 프리팹에서 ReviveDialog 발견 — 1개 + ReviveDialog.Initialize : initialized · 폴백 팝업 box size(1351.11, 675.56) unitsPerPx=1.7778 + ReviveDialog.ShowNowOnce: 폴백 팝업 표시 msg="존 시작점에서 부활합니다. +무료" + [PASS] 부활 팝업 Busy = True + [PASS] 부활 팝업이 떠 있는 동안 카드는 열리지 않는다 + [PASS] 미룸 카운트 1 + [PASS] 픽업이 timeScale 을 건드리지 않았다 + [PASS] 부활 팝업 닫힘 — Busy=False + [PASS] 부활이 닫힌 뒤 카드가 뜬다 + [PASS] 정리 후 닫힘 · 타이머 정지 해제 + +── ⑨ 813w RunGuide 이동 중이면 도착 뒤에 연다 + RunGuide.Active=True State=Off + BeginZone(1) 후 RunGuide.State=Travel (존 1 유도 시작(probe-813o) → (147.0, 2.6, 30.0) [SO 폴백]) + [PASS] 이동 중에는 열리지 않는다 + [PASS] 가이드 대기 1회 + [PASS] 도착(가이드 종료) 뒤 열린다 + +── ⑩ 런 타이머 정지(813p RunDirector.PauseTimer · 세계 정지 0) + [PASS] 시작 상태 = 정지 아님 + [PASS] PauseTimer(true) → ExternalTimerPause=True + [PASS] timeScale 무접촉 = 1.00 + [PASS] 틱을 돌려도 정지가 유지된다(PollPause 가 외부 정지도 같은 층으로 본다) + [PASS] PauseTimer(false) → 해제 + [PASS] 열림=정지 · 닫힘=해제 (짝 맞음 · PauseCalls=2) + 정지 전 경과 = 10.00s + [PASS] 정지 시작 시각이 잡혔다(s_pauseStart=129.45) + [PASS] 프로브가 정지 창을 5초로 벌렸다 + 정지 중 5초 경과 후 = 10.00s (정지가 들으면 그대로 10.00) + [PASS] 🔴 정지 중 5초가 흘러도 런 경과 불변 = 10.00s + [PASS] 정지 누적 = 5.00s 로 집계됐다 + [PASS] 해제 뒤 5초는 정상적으로 흐른다 = 15.00s (대조군) + +── ⑪ GC — 같은 초 100틱 = 표시 쓰기 0회 + [PASS] GC 측정용으로 열림 + 같은 초 100틱: 표시 쓰기 0회 · ΔGetTotalMemory=0 B · gc0 287→287 + [PASS] 표시 쓰기 0회(값이 안 바뀌면 SetText 를 아예 안 부른다) + [PASS] ΔGetTotalMemory = 0 B + 대조군 16 MB: Δ=16781312 B (계기 감도 확인) + [PASS] 대조군은 잡힌다 = 「0」이 진짜 0 + +── ⑫ C8 — enabled_ = 0 이면 아무것도 하지 않는다 + [PASS] Enabled=False + Tick: off + [PASS] ZoneCleared 를 받아도 예약 0 — off + [PASS] 표시 0 + [PASS] 런 타이머 정지 0 + Bind(off): off — 조립 생략 + [PASS] 813p WLRunSettings.Enabled 불변 = True + [PASS] 복구 후 Enabled=True + +── ⑬ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0 + Teardown : 정리 완료 + [PASS] ZoneCleared 구독자 0 — 실측 0 + [PASS] 타이머 정지 해제 확인 + [PASS] 프리팹 UnloadPrefabContents(저장 0) · 대역 오브젝트 파괴 + +RESULT PASS (pass=92 fail=0) diff --git a/AgentScripts/WL813o_Probe.cs b/AgentScripts/WL813o_Probe.cs new file mode 100644 index 000000000..d3eeb6ac1 --- /dev/null +++ b/AgentScripts/WL813o_Probe.cs @@ -0,0 +1,524 @@ +// WL-813o 프로브 — 존 클리어 픽업 카드 실측 (에디트 모드 · Play 0 · 로그인 0). +// unity command run_script --file AgentScripts/WL813o_Probe.cs --entry WL813o_Probe.RunAll +// 산출물: /AgentScripts/WL813o_PROBE.txt +// 🔴 batchmode 는 Time.unscaledTime 이 전혀 전진하지 않는다(실측: run_script 두 번 사이 12 s 를 두어도 11.35 고정). +// 그래서 「멈춘 초」는 813p ShiftClockForProbe + 프로브 전용 리플렉션(s_pauseStart 되감기)으로 재현해 잰다(⑩). +// 🔴 NewGameUI.prefab 은 LoadPrefabContents 로 열고 **저장하지 않는다**(UnloadPrefabContents(false)). + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using UnityEditor; +using TMPro; +using WL.UI; +using WL.Combat.Run; + +public static class WL813o_Probe +{ + const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; + const string kTableTxt = "Assets/ResWork/Table/table_selectskill.txt"; + + static StringBuilder _o; + static int _pass, _fail; + static GameObject _root; + static GameObject _fakeInfos; // table_selectskill · InGameInfo · ActorInfo 대역 + + 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; } } + static string OutPath(string name) { return Path.Combine(Path.Combine(Root, "AgentScripts"), name); } + + public static string RunAll() + { + _o = new StringBuilder(); + _pass = _fail = 0; + _o.AppendLine("# WL813o Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); + _o.AppendLine("playMode=" + Application.isPlaying); + + bool savedDisabled = WLZonePickupSettings.RuntimeDisabled; + try + { + Step0_Asset(); + Step1_OriginalMissing(); + Step2_Fakes(); + Step3_Bind(); + Step4_OpenOnZoneCleared(); + Step5_TimeoutAutoPick(); + Step6_ClickPick(); + Step7_FinalZoneExcluded(); + Step8_RevivePriority(); + Step9_GuideWait(); + Step10_TimerPauseWiring(); + Step11_Gc(); + Step12_C8(); + } + catch (Exception e) + { + _fail++; + _o.AppendLine(" [FAIL] 예외 " + e.GetType().Name + " : " + e.Message); + _o.AppendLine(e.StackTrace); + } + finally + { + WLZonePickupSettings.RuntimeDisabled = savedDisabled; + Step13_Cleanup(); + } + + _o.AppendLine(); + _o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " (pass=" + _pass + " fail=" + _fail + ")"); + string path = OutPath("WL813o_PROBE.txt"); + File.WriteAllText(path, _o.ToString(), new UTF8Encoding(false)); + return _o.ToString() + "\n(파일: " + path + ")"; + } + + // ─────────────────────────────────────────────── ⓪ 에셋 · 계약 + static void Step0_Asset() + { + H("⓪ 에셋 (WLZonePickupSettings · NEW) · 813p 계약"); + WLZonePickupSettings.ClearCache(); + var s = WLZonePickupSettings.Instance; + Chk(s != null, "Resources.Load(\"" + WLZonePickupSettings.ResourcesPath + "\")"); + if (s == null) return; + Chk(WLZonePickupSettings.Enabled, "Enabled = " + WLZonePickupSettings.Enabled); + N("값: cardCount=" + s.cardCount + " timeout=" + s.pickTimeoutSeconds + "s(unscaled=" + s.timeoutUnscaled + + ") first=" + s.autoPickFirstCard + " includeFinalZone=" + s.includeFinalZone + + " guideWait=" + s.waitForGuideArrival + "(max " + s.maxGuideDelaySeconds + "s)" + + " deferRevive=" + s.deferWhileRevive + " pauseTimer=" + s.pauseRunTimer + " holdWorld=" + s.holdWorld + + " stopActors=" + s.stopActorsWhileOpen + " openDelay=" + s.openDelaySeconds + "s"); + Chk(!s.includeFinalZone, "존4(마지막 존) 기본 제외 — 보스 흐름 방해 금지(발주서 §1-1)"); + Chk(!s.holdWorld, "세계 정지 기본 off = 타이머만 정지(발주서 §1-3 권장)"); + Chk(Math.Abs(s.pickTimeoutSeconds - 10f) < 0.001f, "타임아웃 10 s(발주서 §1-2) — 실측 " + s.pickTimeoutSeconds); + Chk(s.cardCount == 3, "3장(기획안 F-3) — 실측 " + s.cardCount); + Chk(s.edge.enabled && s.edge.outlineWidth > 0f, + "남은 초·카드 글자 아웃라인(813y ApplyEdge) outlineWidth=" + s.edge.outlineWidth); + + var rs = WLRunSettings.Instance; + Chk(rs != null, "813p WLRunSettings 존재(존 수·런 길이의 주인 · UI 는 수치를 갖지 않는다)"); + if (rs != null) + N("813p 값(읽기만): runSeconds=" + rs.runSeconds + " 존 " + rs.zoneSpawnerIds.Length + "개"); + } + + // ─────────────── ① 🔴 원본 SelectSkillUI 노드 부재 실측(발주서 §1-1 과 다른 곳) + static void Step1_OriginalMissing() + { + H("① 🔴 원본 SelectSkillUI 노드 실측 — 발주서 §1-1 전제와 다르다"); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + _root = root; + Chk(root != null, "LoadPrefabContents(" + kPrefab + ")"); + if (root == null) return; + + var ui = root.GetComponentsInChildren(true); + var cards = root.GetComponentsInChildren(true); + var gameUi = root.GetComponentsInChildren(true); + var nguiLabels = root.GetComponentsInChildren(true); + var nguiSprites = root.GetComponentsInChildren(true); + var tmp = root.GetComponentsInChildren(true); + N("NewGameUI.prefab 안: SelectSkillUI=" + ui.Length + " SelectSkillCard=" + cards.Length + + " GameUI=" + gameUi.Length + " UILabel=" + nguiLabels.Length + " UISprite=" + nguiSprites.Length + + " TextMeshProUGUI=" + tmp.Length); + Chk(ui.Length == 0, "원본 SelectSkillUI 노드 0개 = 열 대상이 없다"); + Chk(gameUi.Length == 0 && GameUI.Ins == null, + "GameUI 컴포넌트 0개 · GameUI.Ins=null → MyActor.cs:162 의 자동전투 정지 배선은 작동하지 않는다"); + Chk(nguiLabels.Length == 0 && nguiSprites.Length == 0, + "실제 인게임 UI 에 NGUI 위젯 0개(원본 카드 UI 는 NGUI) · TMP 는 " + tmp.Length + "개 = uGUI 트리"); + N("→ 그래서 813o 는 ① 원본 노드가 있으면 그대로 열고, ② 없으면 같은 원본 데이터·효과 경로를 쓰는 런타임 uGUI 패널로 간다."); + } + + // ─────────────── ② 원본 데이터·효과 경로 대역 세우기 (table/InGameInfo/ActorInfo) + static void Step2_Fakes() + { + H("② 원본 데이터·효과 경로 — table_selectskill / InGameInfo / ActorInfo"); + _fakeInfos = new GameObject("WL813o_Probe_Infos"); + _fakeInfos.hideFlags = HideFlags.HideAndDontSave; + + var tbl = _fakeInfos.AddComponent(); + tbl.m_json = AssetDatabase.LoadAssetAtPath(kTableTxt); + Chk(tbl.m_json != null, "테이블 원본 TextAsset " + kTableTxt); + // 에디트 모드는 Awake 를 부르지 않는다 → 원본 Awake 를 그대로 태운다(파싱 로직 무수정). + // SendMessage 는 HideAndDontSave 오브젝트에서 'ShouldRunBehaviour()' Assert 를 남기므로 직접 호출한다. + var awake = typeof(table_selectskill).GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance); + Chk(awake != null, "원본 table_selectskill.Awake 확보"); + if (awake != null) awake.Invoke(tbl, null); + Chk(table_selectskill.Ins != null, "table_selectskill.Ins 설정(원본 Awake 경로)"); + + var info = _fakeInfos.AddComponent(); + InGameInfo.Ins = info; // Awake 는 무거워서 부르지 않는다(Ins 만 세운다) + Chk(InGameInfo.Ins != null, "InGameInfo.Ins 설정"); + + var actors = _fakeInfos.AddComponent(); + ActorInfo.Ins = actors; + Chk(ActorInfo.Ins != null, "ActorInfo.Ins 설정(All_Stop 대상 0 = 무해)"); + + int all = table_selectskill.Ins != null ? table_selectskill.Ins.Get_DataList().Count : 0; + N("테이블 행 수 = " + all); + Chk(all > 3, "카드 3장을 뽑기에 충분한 행 수"); + + var draw = table_selectskill.Ins.Get_DataList(3); + Chk(draw != null && draw.Count == 3, "원본 뽑기 Get_DataList(3) = 3행(셔플·보유 제외·PreSkill 규칙 그대로)"); + if (draw != null) + for (int i = 0; i < draw.Count; i++) + { + var r = draw[i] as SelectSkillTableData; + if (r != null) N(" 뽑기 예시 " + i + ": id=" + r.ID + " \"" + r.Name + "\" pet=" + r.PetSkill); + } + } + + // ─────────────── ③ 노드 조립 · 구독 + static void Step3_Bind() + { + H("③ 노드 조립 — WL_ZonePickup(런타임 생성 · HideFlags.DontSave) · 구독 3종"); + if (_root == null) { Chk(false, "프리팹 루트 없음"); return; } + + int before = RunEvents.ZoneCleared.Count; + N("Subscribe : " + ZonePickup.Subscribe(true)); + Chk(RunEvents.ZoneCleared.Count == before + 1, "ZoneCleared 구독 +1 — 실측 " + RunEvents.ZoneCleared.Count); + N("Subscribe(중복): " + ZonePickup.Subscribe(true)); + Chk(RunEvents.ZoneCleared.Count == before + 1, "2회 호출해도 +1(중복 구독 0)"); + + var hud = WLVignetteUtil.FindUiPath(_root.transform, "IngameUIs/WL_HUD"); + Chk(hud != null, "IngameUIs/WL_HUD 경로 실측(813y/813q 와 같은 부모)"); + if (hud != null) + { + bool safe = hud.GetComponentInParent(true) != null || hud.GetComponent() != null; + Chk(safe, "WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안"); + } + N("Bind : " + ZonePickup.Bind(_root.transform)); + Chk(ZonePickup.Bound, "WL_ZonePickup 노드 생성"); + N("배치 : " + ZonePickup.ApplyLayout()); + Chk(!ZonePickup.IsOpen, "조립 직후에는 닫혀 있다"); + } + + // ─────────────── ④ 존 클리어 → 열림 · 3장 + static void Step4_OpenOnZoneCleared() + { + H("④ 가짜 ZoneCleared(존 0) → 카드 3장 열림"); + var s = WLZonePickupSettings.Instance; + ZonePickup.ResetState(); + RunDirector.EnsureSubscribed(); + RunDirector.StartRun("probe-813o"); + N("런 시작: phase=" + RunDirector.Phase + " zoneCount=" + RunDirector.ZoneCount); + + WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 31.5f, 12, 12, false); + Chk(ZonePickup.ZoneClearedSeen == 1, "ZoneCleared 수신 1 — 실측 " + ZonePickup.ZoneClearedSeen); + Chk(ZonePickup.Phase == ZonePickupPhase.Pending, "예약 상태 = " + ZonePickup.Phase); + Chk(ZonePickup.PendingZone == 0, "예약 존 = " + ZonePickup.PendingZone); + + float t0 = ZonePickup.NowClock(); + N("Tick(지연 전): " + ZonePickup.Tick(t0)); + Chk(!ZonePickup.IsOpen, "openDelaySeconds 전에는 열리지 않는다"); + + N("Tick(지연 후): " + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f)); + Chk(ZonePickup.IsOpen, "카드 열림 — " + ZonePickup.Dump()); + Chk(!ZonePickup.UsedOriginalUi, "원본 노드가 없어 런타임 패널 경로(폴백)로 열렸다"); + Chk(ZonePickup.CardCount == 3, "카드 3장 — 실측 " + ZonePickup.CardCount); + for (int i = 0; i < ZonePickup.CardCount; i++) + N(" 카드 " + i + ": id=" + ZonePickup.CardId(i) + " \"" + ZonePickup.CardNameText(i) + "\""); + Chk(ZonePickup.CardId(0) != 0 && ZonePickup.CardId(1) != 0 && ZonePickup.CardId(2) != 0, "3장 전부 실제 스킬 ID"); + Chk(ZonePickup.CardId(0) != ZonePickup.CardId(1) && ZonePickup.CardId(1) != ZonePickup.CardId(2), "중복 없음"); + Chk(!string.IsNullOrEmpty(ZonePickup.CardNameText(0)), "카드 이름이 테이블 값으로 채워졌다"); + Chk(ZonePickup.TitleText == s.titleText, "제목 = \"" + ZonePickup.TitleText + "\""); + Chk(ZonePickup.TimerText.Contains("10"), "남은 초 첫 표시 = \"" + ZonePickup.TimerText + "\""); + Chk(ZonePickup.ActorsStopped, "원본 ActorInfo.All_Stop(true) 도달 = 자동전투 정지"); + Chk(RunDirector.ExternalTimerPause, "런 타이머 정지 요청 on(RunDirector.PauseTimer) — 세계 정지 없음"); + Chk(Math.Abs(Time.timeScale - 1f) < 0.0001f, "timeScale 무접촉 = " + Time.timeScale.ToString("F2") + "(holdWorld off)"); + } + + // ─────────────── ⑤ 타임아웃 → 첫 카드 자동 선택 · 닫힘 + static void Step5_TimeoutAutoPick() + { + H("⑤ 타임아웃 → 첫 카드 자동 선택 · 닫힘 · 정지 해제"); + var s = WLZonePickupSettings.Instance; + if (!ZonePickup.IsOpen) { Chk(false, "④에서 열려 있어야 한다"); return; } + + int firstId = ZonePickup.CardId(0); + float t0 = ZonePickup.NowClock(); + + // 카운트다운 표시 — 남은 초가 바뀔 때마다 1회씩만 쓴다 + int w0 = ZonePickup.TimerWrites; + ZonePickup.Tick(t0 + s.openDelaySeconds + 3.2f); + N("6.8s 남음 표시 = \"" + ZonePickup.TimerText + "\" (sec=" + ZonePickup.ShownSecond + ")"); + Chk(ZonePickup.ShownSecond == 7, "올림 표기 6.8 → 7 — 실측 " + ZonePickup.ShownSecond); + ZonePickup.Tick(t0 + s.openDelaySeconds + 8.5f); + N("1.5s 남음 표시 = \"" + ZonePickup.TimerText + "\" (경고색 구간)"); + Chk(ZonePickup.ShownSecond == 2, "올림 표기 1.5 → 2 — 실측 " + ZonePickup.ShownSecond); + Chk(ZonePickup.TimerWrites > w0, "표시 쓰기 발생 " + (ZonePickup.TimerWrites - w0) + "회"); + + N("Tick(타임아웃): " + ZonePickup.Tick(t0 + s.openDelaySeconds + s.pickTimeoutSeconds + 0.01f)); + Chk(ZonePickup.TimeoutCount == 1, "타임아웃 1회 — 실측 " + ZonePickup.TimeoutCount); + Chk(ZonePickup.PickCount == 1, "선택 1회 — 실측 " + ZonePickup.PickCount); + Chk(!ZonePickup.IsOpen, "닫힘 — " + ZonePickup.Dump()); + var dic = InGameInfo.Ins != null ? InGameInfo.Ins.Get_SelectSkills() : null; + Chk(dic != null && dic.ContainsKey(firstId), + "원본 InGameInfo.Add_SelectSkill(" + firstId + ") 도달 = 첫 카드가 적용됐다(효과는 원본 로직 그대로)"); + Chk(!ZonePickup.ActorsStopped, "원본 All_Stop(false) 도달 = 자동전투 복귀"); + Chk(!RunDirector.ExternalTimerPause, "런 타이머 정지 해제"); + Chk(ZonePickup.PicksThisRun == 1, "이번 런의 픽업 수 = " + ZonePickup.PicksThisRun); + } + + // ─────────────── ⑥ 카드 클릭 경로 + static void Step6_ClickPick() + { + H("⑥ 카드 버튼 클릭 경로(자동이 아닌 사용자 선택)"); + var s = WLZonePickupSettings.Instance; + ZonePickup.ResetState(); + WLZonePickupProbeHooks.RaiseZoneCleared(1, 813002, 62f, 14, 14, false); + float t0 = ZonePickup.NowClock(); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); + Chk(ZonePickup.IsOpen, "존 1 클리어로 다시 열림"); + int id1 = ZonePickup.CardId(1); + N("클릭 카드 1: id=" + id1 + " \"" + ZonePickup.CardNameText(1) + "\""); + N("ClickCard(1): " + ZonePickup.ClickCard(1)); + Chk(!ZonePickup.IsOpen, "클릭 즉시 닫힘"); + Chk(ZonePickup.TimeoutCount == 0, "타임아웃 아님(클릭 선택) — 실측 " + ZonePickup.TimeoutCount); + var dic = InGameInfo.Ins != null ? InGameInfo.Ins.Get_SelectSkills() : null; + Chk(dic != null && dic.ContainsKey(id1), "클릭한 카드의 효과가 원본 경로로 적용됐다"); + } + + // ─────────────── ⑦ 마지막 존 제외 + static void Step7_FinalZoneExcluded() + { + H("⑦ 마지막 존(존4) 제외 · SO 로 포함 전환"); + var s = WLZonePickupSettings.Instance; + ZonePickup.ResetState(); + int last = RunDirector.ZoneCount - 1; + N("존 수 = " + RunDirector.ZoneCount + " (마지막 인덱스 " + last + ")"); + + WLZonePickupProbeHooks.RaiseZoneCleared(last, 813004, 150f, 8, 8, true); + Chk(ZonePickup.Phase == ZonePickupPhase.Idle, "마지막 존은 카드를 띄우지 않는다 — " + ZonePickup.LastSkipReason); + Chk(ZonePickup.SkipCount == 1, "건너뜀 1회 — 실측 " + ZonePickup.SkipCount); + + bool saved = s.includeFinalZone; + s.includeFinalZone = true; + try + { + WLZonePickupProbeHooks.RaiseZoneCleared(last, 813004, 150f, 8, 8, true); + Chk(ZonePickup.Phase == ZonePickupPhase.Pending, "includeFinalZone=1 이면 마지막 존도 예약된다(코드 변경 0)"); + } + finally { s.includeFinalZone = saved; } + ZonePickup.ResetState(); + } + + // ─────────────── ⑧ 부활 팝업 우선 + static void Step8_RevivePriority() + { + H("⑧ 813i/813z 부활 팝업과 동시 — 부활 우선(카드는 미룬다)"); + var s = WLZonePickupSettings.Instance; + var dlg = _root != null ? _root.GetComponentsInChildren(true) : new ReviveDialog[0]; + Chk(dlg != null && dlg.Length > 0, "프리팹에서 ReviveDialog 발견 — " + (dlg != null ? dlg.Length : 0) + "개"); + if (dlg == null || dlg.Length == 0) return; + var d = dlg[0]; + N("ReviveDialog.Initialize : " + d.Initialize()); + N("ReviveDialog.ShowNowOnce: " + d.ShowNowOnce()); + Chk(ReviveDialog.Busy, "부활 팝업 Busy = " + ReviveDialog.Busy); + + ZonePickup.ResetState(); + WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 20f, 12, 12, false); + float t0 = ZonePickup.NowClock(); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); + Chk(!ZonePickup.IsOpen, "부활 팝업이 떠 있는 동안 카드는 열리지 않는다"); + Chk(ZonePickup.ReviveDeferCount > 0, "미룸 카운트 " + ZonePickup.ReviveDeferCount); + Chk(Math.Abs(Time.timeScale - 1f) < 0.0001f || ReviveDialog.Busy, "픽업이 timeScale 을 건드리지 않았다"); + + d.HideNow(); + Chk(!ReviveDialog.Busy, "부활 팝업 닫힘 — Busy=" + ReviveDialog.Busy); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.02f); + Chk(ZonePickup.IsOpen, "부활이 닫힌 뒤 카드가 뜬다"); + ZonePickup.Close("프로브 정리"); + Chk(!ZonePickup.IsOpen && !RunDirector.ExternalTimerPause, "정리 후 닫힘 · 타이머 정지 해제"); + } + + // ─────────────── ⑨ 813w 가이드 이동 중 대기 + static void Step9_GuideWait() + { + H("⑨ 813w RunGuide 이동 중이면 도착 뒤에 연다"); + var s = WLZonePickupSettings.Instance; + N("RunGuide.Active=" + RunGuide.Active + " State=" + RunGuide.State); + ZonePickup.ResetState(); + + if (!RunGuide.Active) + { + N("가이드 축이 꺼져 있어 Travel 상태를 만들 수 없다 — 대기 분기는 「미확인」(인게임 QA)."); + Chk(true, "가이드 off 면 대기 없이 바로 연다(발주서 §1-1 의 폴백 경로)"); + return; + } + RunGuide.BeginZone(1, "probe-813o"); + N("BeginZone(1) 후 RunGuide.State=" + RunGuide.State + " (" + RunGuide.LastLog + ")"); + WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 20f, 12, 12, false); + float t0 = ZonePickup.NowClock(); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); + if (RunGuide.State == RunGuideState.Travel) + { + Chk(!ZonePickup.IsOpen, "이동 중에는 열리지 않는다"); + Chk(ZonePickup.GuideWaitCount > 0, "가이드 대기 " + ZonePickup.GuideWaitCount + "회"); + RunGuide.StopGuide("probe-813o"); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.02f); + Chk(ZonePickup.IsOpen, "도착(가이드 종료) 뒤 열린다"); + } + else + { + N("Travel 상태가 만들어지지 않았다(에디트 모드엔 PC·NavMesh 가 없다) — 대기 분기는 「미확인」."); + Chk(true, "대기 상한 " + s.maxGuideDelaySeconds + "s 를 넘으면 이동 중이어도 연다(코드 경로 존재)"); + } + ZonePickup.Close("프로브 정리"); + } + + // ─────────────── ⑩ 런 타이머 정지 배선 + static void Step10_TimerPauseWiring() + { + H("⑩ 런 타이머 정지(813p RunDirector.PauseTimer · 세계 정지 0)"); + ZonePickup.ResetState(); + Chk(!RunDirector.ExternalTimerPause, "시작 상태 = 정지 아님"); + + RunDirector.PauseTimer(true); + Chk(RunDirector.ExternalTimerPause, "PauseTimer(true) → ExternalTimerPause=True"); + Chk(Math.Abs(Time.timeScale - 1f) < 0.0001f, "timeScale 무접촉 = " + Time.timeScale.ToString("F2")); + RunDirector.ForceTick(); + Chk(RunDirector.ExternalTimerPause, "틱을 돌려도 정지가 유지된다(PollPause 가 외부 정지도 같은 층으로 본다)"); + RunDirector.PauseTimer(false); + Chk(!RunDirector.ExternalTimerPause, "PauseTimer(false) → 해제"); + + // 픽업이 열렸다 닫히면 반드시 짝이 맞는다(정지가 새지 않는다) + var s = WLZonePickupSettings.Instance; + WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 10f, 12, 12, false); + float t0 = ZonePickup.NowClock(); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); + bool onWhileOpen = RunDirector.ExternalTimerPause; + ZonePickup.Tick(t0 + s.openDelaySeconds + s.pickTimeoutSeconds + 0.01f); + Chk(onWhileOpen && !RunDirector.ExternalTimerPause, "열림=정지 · 닫힘=해제 (짝 맞음 · PauseCalls=" + ZonePickup.PauseCalls + ")"); + + // 🔴 「멈춘 초」 실측 — batchmode 는 Time.unscaledTime 이 전혀 전진하지 않는다(호출 사이에도 11.35 고정). + // 그래서 813p 의 ShiftClockForProbe(시작 시각 되감기)에 더해, 프로브가 **정지 시작 시각도 같이 되감아** + // 「정지 중에 N 초가 흘렀다」를 그대로 재현한다(게임 코드 수정 0 · 프로브 전용 리플렉션). + RunDirector.ResetAll(); + RunDirector.StartRun("probe-813o-pause"); + RunDirector.ShiftClockForProbe(10f); + float e0 = RunDirector.Elapsed; + N("정지 전 경과 = " + e0.ToString("F2") + "s"); + RunDirector.PauseTimer(true); + Chk(PauseStartRaw() >= 0f, "정지 시작 시각이 잡혔다(s_pauseStart=" + PauseStartRaw().ToString("F2") + ")"); + + RunDirector.ShiftClockForProbe(5f); // 5초가 흘렀다 + bool shifted = ShiftPauseStart(5f); // 그 5초는 「정지 중」이었다 + Chk(shifted, "프로브가 정지 창을 5초로 벌렸다"); + float e1 = RunDirector.Elapsed; + N("정지 중 5초 경과 후 = " + e1.ToString("F2") + "s (정지가 들으면 그대로 " + e0.ToString("F2") + ")"); + Chk(Math.Abs(e1 - e0) < 0.01f, "🔴 정지 중 5초가 흘러도 런 경과 불변 = " + e1.ToString("F2") + "s"); + + RunDirector.PauseTimer(false); + float total = PausedTotalRaw(); + Chk(Math.Abs(total - 5f) < 0.01f, "정지 누적 = " + total.ToString("F2") + "s 로 집계됐다"); + RunDirector.ShiftClockForProbe(5f); + float e2 = RunDirector.Elapsed; + Chk(Math.Abs(e2 - (e0 + 5f)) < 0.01f, "해제 뒤 5초는 정상적으로 흐른다 = " + e2.ToString("F2") + "s (대조군)"); + RunDirector.EndRun(RunOutcome.Abandon); + RunDirector.ResetAll(); + RunDirector.StartRun("probe-813o"); // 뒤 절차용으로 런을 되살린다 + } + + static FieldInfo Fld(string name) + { + return typeof(RunDirector).GetField(name, BindingFlags.NonPublic | BindingFlags.Static); + } + static float PauseStartRaw() { var f = Fld("s_pauseStart"); return f == null ? -2f : (float)f.GetValue(null); } + static float PausedTotalRaw() { var f = Fld("s_pausedTotal"); return f == null ? -2f : (float)f.GetValue(null); } + static bool ShiftPauseStart(float seconds) + { + var f = Fld("s_pauseStart"); + if (f == null) return false; + float v = (float)f.GetValue(null); + if (v < 0f) return false; + f.SetValue(null, v - seconds); + return true; + } + + // ─────────────── ⑪ GC + static void Step11_Gc() + { + H("⑪ GC — 같은 초 100틱 = 표시 쓰기 0회"); + var s = WLZonePickupSettings.Instance; + ZonePickup.ResetState(); + WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 10f, 12, 12, false); + float t0 = ZonePickup.NowClock(); + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); + Chk(ZonePickup.IsOpen, "GC 측정용으로 열림"); + + float t = t0 + s.openDelaySeconds + 2.51f; // 남은 7.5s → 올림 8 · 0.1s 동안 같은 초 + ZonePickup.Tick(t); // 첫 쓰기 + int w0 = ZonePickup.TimerWrites; + long m0 = GC.GetTotalMemory(false); + int gc0 = GC.CollectionCount(0); + for (int i = 0; i < 100; i++) ZonePickup.Tick(t + i * 0.001f); // 같은 초 + long m1 = GC.GetTotalMemory(false); + int gc1 = GC.CollectionCount(0); + N("같은 초 100틱: 표시 쓰기 " + (ZonePickup.TimerWrites - w0) + "회 · ΔGetTotalMemory=" + (m1 - m0) + + " B · gc0 " + gc0 + "→" + gc1); + Chk(ZonePickup.TimerWrites == w0, "표시 쓰기 0회(값이 안 바뀌면 SetText 를 아예 안 부른다)"); + Chk(m1 - m0 == 0L, "ΔGetTotalMemory = 0 B"); + + // 대조군 — 계기 감도 확인(813q 실측대로 이 런타임은 16 MB 규모라야 잡힌다) + long c0 = GC.GetTotalMemory(false); + var junk = new byte[16 * 1024 * 1024]; + junk[0] = 1; + long c1 = GC.GetTotalMemory(false); + N("대조군 16 MB: Δ=" + (c1 - c0) + " B (계기 감도 확인)"); + Chk(c1 - c0 > 0L, "대조군은 잡힌다 = 「0」이 진짜 0"); + + ZonePickup.Close("GC 측정 종료"); + } + + // ─────────────── ⑫ C8 롤백 + static void Step12_C8() + { + H("⑫ C8 — enabled_ = 0 이면 아무것도 하지 않는다"); + ZonePickup.ResetState(); + WLZonePickupSettings.RuntimeDisabled = true; + Chk(!WLZonePickupSettings.Enabled, "Enabled=False"); + N("Tick: " + ZonePickup.Tick(ZonePickup.NowClock())); + WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 10f, 12, 12, false); + Chk(ZonePickup.Phase == ZonePickupPhase.Idle, "ZoneCleared 를 받아도 예약 0 — " + ZonePickup.LastSkipReason); + Chk(!ZonePickup.IsOpen, "표시 0"); + Chk(!RunDirector.ExternalTimerPause, "런 타이머 정지 0"); + N("Bind(off): " + ZonePickup.Bind(_root != null ? _root.transform : null)); + + // 813p 축은 그대로 살아 있어야 한다 + Chk(WLRunSettings.Enabled, "813p WLRunSettings.Enabled 불변 = " + WLRunSettings.Enabled); + WLZonePickupSettings.RuntimeDisabled = false; + Chk(WLZonePickupSettings.Enabled, "복구 후 Enabled=True"); + } + + // ─────────────── ⑬ 정리 + static void Step13_Cleanup() + { + H("⑬ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0"); + try + { + N("Teardown : " + ZonePickup.Teardown()); + Chk(RunEvents.ZoneCleared.Count == 0, "ZoneCleared 구독자 0 — 실측 " + RunEvents.ZoneCleared.Count); + RunDirector.EndRun(RunOutcome.Abandon); + RunDirector.ResetAll(); + RunDirector.Unsubscribe(); + RunDirector.PauseTimer(false); + Chk(!RunDirector.ExternalTimerPause, "타이머 정지 해제 확인"); + + table_selectskill.Ins = null; + InGameInfo.Ins = null; + ActorInfo.Ins = null; + if (_fakeInfos != null) UnityEngine.Object.DestroyImmediate(_fakeInfos); + if (_root != null) PrefabUtility.UnloadPrefabContents(_root); // 저장 0 + _root = null; _fakeInfos = null; + Chk(true, "프리팹 UnloadPrefabContents(저장 0) · 대역 오브젝트 파괴"); + } + catch (Exception e) { _fail++; _o.AppendLine(" [FAIL] 정리 예외 " + e.Message); } + } + +} diff --git a/Assets/WL/Combat/Run/RunDirector.cs b/Assets/WL/Combat/Run/RunDirector.cs index fefbd498a..da5d92162 100644 --- a/Assets/WL/Combat/Run/RunDirector.cs +++ b/Assets/WL/Combat/Run/RunDirector.cs @@ -140,6 +140,15 @@ namespace WL.Combat.Run } } + /// 외부 타이머 정지(813o 픽업 카드) — 세계 정지 없이 런 타이머만 멈춘다. + public static bool ExternalTimerPause { get { return s_extPause; } } + static bool s_extPause; + public static void PauseTimer(bool pause) + { + if (s_extPause == pause) return; + s_extPause = pause; var c = Cfg; if (c != null) PollPause(c, Time.unscaledTime); + } + // ───────────────────────────────────────────────────────────────────── // 구독 // ───────────────────────────────────────────────────────────────────── @@ -548,8 +557,7 @@ namespace WL.Combat.Run static void PollPause(WLRunSettings c, float now) { - if (!c.pauseTimerWhileWorldHeld) { if (s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; } return; } - bool held = DeathFlow.WorldHeld; + bool held = (c.pauseTimerWhileWorldHeld && DeathFlow.WorldHeld) || s_extPause; // 813o: 외부 정지도 같은 층 if (held && s_pauseStart < 0f) s_pauseStart = now; else if (!held && s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; } } diff --git a/Assets/WL/UI/Scripts/ZonePickup.cs b/Assets/WL/UI/Scripts/ZonePickup.cs new file mode 100644 index 000000000..c81c9838c --- /dev/null +++ b/Assets/WL/UI/Scripts/ZonePickup.cs @@ -0,0 +1,809 @@ +// ───────────────────────────────────────────────────────────────────────────── +// ZonePickup.cs — 존 클리어 픽업 카드(로그라이크 3장 중 1장) (WL-813o · #813) +// +// 기준서 §D-2 813o 행 + 기획안 F-3 + 발주서 WL-813o §1(Lead 설계 결정). +// 트리거 계약 = 813p 완료보고 §4 `RunEvents.ZoneCleared`(`Assets/WL/Combat/Run/RunEvents.cs` · 읽기만) +// → 원본 `MobControlMgr` 전멸 훅 **0**. +// +// ■ 🔴 실측(2026-09-09 · 발주서 §1-1 과 다른 곳) — **원본 `SelectSkillUI` 노드는 존재하지 않는다.** +// · `SelectSkillUI.cs` 스크립트 GUID `92b16098987b86b4d850255ef6a56f64` 는 `Assets` 전수 검색에서 +// **자기 `.meta` 한 곳에서만** 나온다(어떤 `.prefab`/`.unity` 에도 붙어 있지 않다). +// · 상위 `GameUI`(GUID `abec0a6a…`)도 씬 27개·`Res_Addr`·`ResWork`·`Resources` 어디에도 없다 +// → `GameUI.Ins` 는 항상 null → `MyActor.cs:162` 의 자동전투 정지 배선은 **작동한 적이 없다**. +// · 원본 카드 UI 는 **NGUI**(`UILabel`/`UISprite`)인데 실제 인게임 UI(`Res_Addr/MainUI/NewGameUI.prefab`)에는 +// NGUI 컴포넌트가 **0개**다(UIPanel/UIRoot/UICamera/UILabel/UISprite 전부 0) → 붙여도 렌더링되지 않는다. +// 그래서 **가장 가까운 방식**으로 간다 — +// ① 원본 노드가 있으면 그대로 연다(`SelectSkillUI.Set` / `SelectSkillCard.OnClick_SelectSkill` +// 전부 이미 public · `Assets/Script/**` 수정 **0줄**). 없으면 ②. +// ② 카드 데이터·효과·자동전투 정지를 **전부 원본 경로 그대로** 쓰고 표시만 런타임 uGUI 로 만든다: +// 뽑기 = `table_selectskill.Ins.Get_DataList(n)`(원본 셔플·중복 제외·PreSkill 규칙 그대로) +// 효과 = `InGameInfo.Ins.Add_SelectSkill(id)` (원본 `SelectSkillUI.SkillSelect` 59행과 같은 줄) +// 정지 = `ActorInfo.Ins.All_Stop(true/false)` (원본 `SelectSkillUI` 29·61행과 같은 줄) +// +// ■ 순서 — ① 813w `RunGuide` 가 다음 존으로 이동 중(Travel)이면 **도착 뒤에** 연다(최대 `maxGuideDelaySeconds`). +// ② 813i/813z 부활 팝업이 떠 있으면 **부활이 우선**이고 카드는 미룬다(`deferWhileRevive`). +// +// ■ 런 타이머 — 카드가 열린 동안 813p `RunDirector.PauseTimer(true)`(세계 정지 없음 · 전투는 계속). +// `holdWorld` 를 켜면 813z `DeathFlow.HoldWorld` 와 같은 `timeScale=0` 방식으로도 갈 수 있다(기본 off). +// +// ■ GC 0 — 남은 초는 **표시값이 바뀐 초에만** `SetText(format, float)`(TMP 무할당 오버로드)로 쓴다. +// 카드 문자열은 열 때 1회만 쓴다. 로그 문자열은 **만들기 전에** `verboseLog` 로 막는다(811b FIX-3 규약). +// +// ■ 프리팹 diff 0 — 노드는 전부 런타임 생성(`HideFlags.DontSave`) · 1회 만들고 재사용. +// ■ C8 롤백 — `WLZonePickupSettings.enabled_ = 0` → 구독 0 · 노드 0 · 표시 0 · 타이머 정지 0. +// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.UI; +using WL.Combat.Run; +using WL.Combat.Survival; + +namespace WL.UI +{ + /// 픽업 카드의 진행 단계. + public enum ZonePickupPhase + { + /// 대기 — 예약 없음. + Idle = 0, + /// 존 클리어를 받아 열기를 예약했다(지연·가이드·부활 대기). + Pending = 1, + /// 카드가 열려 있다. + Open = 2, + } + + /// 존 클리어 픽업 카드(정적 · 노드는 런타임 생성 · 프리팹 diff 0). + public static class ZonePickup + { + // ── 노드 ────────────────────────────────────────────────────────────── + private static Transform s_uiRoot; + private static RectTransform s_root; + private static CanvasGroup s_group; + private static Image s_dim; + private static RectTransform s_box; + private static Image s_boxBg; + private static TextMeshProUGUI s_title; + private static TextMeshProUGUI s_timer; + private static RectTransform s_cardRoot; + private static RectTransform[] s_card = new RectTransform[0]; + private static Image[] s_cardBg = new Image[0]; + private static TextMeshProUGUI[] s_cardName = new TextMeshProUGUI[0]; + private static TextMeshProUGUI[] s_cardDesc = new TextMeshProUGUI[0]; + private static Button[] s_cardButton = new Button[0]; + private static TMP_FontAsset s_font; + + // ── 상태 ────────────────────────────────────────────────────────────── + private static bool s_subscribed; + private static ZonePickupPhase s_phase; + private static float s_pendingSince = -1f; // 예약 시각(unscaled) + private static float s_openAt = -1f; // 열린 시각 + private static int s_pendingZone = -1; + private static int s_picksThisRun; + private static bool s_timerPaused, s_worldHeld, s_actorsStopped; + private static float s_prevTimeScale = 1f; + private static int s_shownSec = int.MinValue; + private static bool s_shownWarn; + private static bool s_usedOriginalUi; + + /// 이번에 뽑힌 카드의 스킬 ID(0 = 빈 칸). 폴백 패널 경로에서만 채운다. + private static readonly List s_ids = new List(8); + private static readonly List s_rows = new List(8); + + // ── 진단 (프로브가 읽는다) ──────────────────────────────────────────── + public static int ZoneClearedSeen, OpenCount, PickCount, TimeoutCount, SkipCount, + GuideWaitCount, ReviveDeferCount, GiveUpCount, TimerWrites, PauseCalls; + public static string LastLog = ""; + public static string LastSkipReason = ""; + + public static bool Subscribed { get { return s_subscribed; } } + public static bool Bound { get { return s_root != null; } } + public static bool IsOpen { get { return s_phase == ZonePickupPhase.Open; } } + public static ZonePickupPhase Phase { get { return s_phase; } } + public static int PendingZone { get { return s_pendingZone; } } + public static int PicksThisRun { get { return s_picksThisRun; } } + public static bool TimerPaused { get { return s_timerPaused; } } + public static bool WorldHeldByPickup { get { return s_worldHeld; } } + public static bool ActorsStopped { get { return s_actorsStopped; } } + public static bool UsedOriginalUi { get { return s_usedOriginalUi; } } + public static int CardCount { get { return s_ids.Count; } } + public static int CardId(int i) { return i >= 0 && i < s_ids.Count ? s_ids[i] : 0; } + public static string TitleText { get { return s_title != null ? s_title.text : ""; } } + public static string TimerText { get { return s_timer != null ? s_timer.text : ""; } } + public static int ShownSecond { get { return s_shownSec; } } + public static string CardNameText(int i) + { + return i >= 0 && i < s_cardName.Length && s_cardName[i] != null ? s_cardName[i].text : ""; + } + + private static WLZonePickupSettings St { get { return WLZonePickupSettings.Instance; } } + + // ── 부팅 (노드 0 · 프리팹 diff 0) ───────────────────────────────────── + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void Boot() + { + if (!WLZonePickupSettings.Enabled) return; + Subscribe(true); + WLZonePickupRunner.Ensure(); + } + + // ── 구독 (813p RunEvents 3종) ───────────────────────────────────────── + public static string Subscribe(bool on) + { + if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; + if (on) + { + RunEvents.ZoneCleared.Add(OnZoneCleared); + RunEvents.RunStarted.Add(OnRunStarted); + RunEvents.RunEnded.Add(OnRunEnded); + } + else + { + RunEvents.ZoneCleared.Remove(OnZoneCleared); + RunEvents.RunStarted.Remove(OnRunStarted); + RunEvents.RunEnded.Remove(OnRunEnded); + } + s_subscribed = on; + return "구독=" + on + " (ZoneCleared/RunStarted/RunEnded)"; + } + + // ── 이벤트 핸들러 (in 파라미터 = 복사 0 · 813p 계약) ────────────────── + private static void OnRunStarted(in RunStartedEvent e) + { + s_picksThisRun = 0; + if (s_phase != ZonePickupPhase.Idle) Close("런 시작"); + } + + private static void OnRunEnded(in RunResult r) + { + if (s_phase != ZonePickupPhase.Idle) Close("런 종료"); + s_pendingZone = -1; + } + + private static void OnZoneCleared(in ZoneClearedEvent e) + { + ZoneClearedSeen++; + var s = St; + if (s == null || !WLZonePickupSettings.Enabled) { Skip("off"); return; } + if (e.zoneIndex < s.minZoneIndex) { Skip("존 인덱스 하한 미만"); return; } + + // 마지막 존 제외(기본) — allZonesCleared 이거나 인덱스가 마지막이면 보스 흐름을 방해하지 않는다. + if (!s.includeFinalZone) + { + int last = RunDirector.ZoneCount - 1; + if (e.allZonesCleared || (last >= 0 && e.zoneIndex >= last)) { Skip("마지막 존 제외"); return; } + } + if (s.maxPicksPerRun > 0 && s_picksThisRun >= s.maxPicksPerRun) { Skip("런당 상한 도달"); return; } + if (s_phase != ZonePickupPhase.Idle) { Skip("이미 진행 중"); return; } + + s_phase = ZonePickupPhase.Pending; + s_pendingZone = e.zoneIndex; + s_pendingSince = NowClock(); + if (s.verboseLog) Debug.Log("[ZonePickup] 존 클리어 #" + e.zoneIndex + " — 카드 예약"); + } + + private static void Skip(string reason) + { + SkipCount++; + LastSkipReason = reason; + } + + // ── 매 프레임 ───────────────────────────────────────────────────────── + public static string Tick(float now) + { + var s = St; + if (s == null || !WLZonePickupSettings.Enabled) + { + if (s_phase != ZonePickupPhase.Idle) Close("비활성"); + return "off"; + } + + if (s_phase == ZonePickupPhase.Pending) return TickPending(s, now); + if (s_phase == ZonePickupPhase.Open) return TickOpen(s, now); + return "대기"; + } + + private static string TickPending(WLZonePickupSettings s, float now) + { + float waited = now - s_pendingSince; + if (waited < s.openDelaySeconds) return "지연 대기"; + + if (waited > s.maxDeferSeconds) + { + GiveUpCount++; + s_phase = ZonePickupPhase.Idle; + s_pendingZone = -1; + if (s.verboseLog) Debug.Log("[ZonePickup] 대기 상한 초과 — 이번 픽업 취소"); + return "포기(대기 상한)"; + } + + if (s.deferWhileRevive && ReviveBusy()) { ReviveDeferCount++; return "부활 우선 — 미룸"; } + + if (s.waitForGuideArrival && waited < s.maxGuideDelaySeconds && GuideTravelling()) + { + GuideWaitCount++; + return "가이드 이동 중 — 도착 대기"; + } + + return Open(s, now); + } + + private static string TickOpen(WLZonePickupSettings s, float now) + { + // 원본 노드 경로에서 원본이 스스로 닫았으면(클릭·원본 타임아웃) 정리한다. + if (s_usedOriginalUi && !OriginalUiActive()) { Close("원본이 닫음"); return "원본이 닫음"; } + + float left = s.pickTimeoutSeconds - (now - s_openAt); + WriteTimer(s, left); + if (left > 0f) return "열림"; + + TimeoutCount++; + if (s.verboseLog) Debug.Log("[ZonePickup] 타임아웃 — 자동 선택"); + return Pick(PickIndexOnTimeout(s), true); + } + + private static int PickIndexOnTimeout(WLZonePickupSettings s) + { + if (s.autoPickFirstCard) return 0; + int n = s_usedOriginalUi ? OriginalCardCount() : s_ids.Count; + return n > 1 ? Random.Range(0, n) : 0; + } + + // ── 열기 ────────────────────────────────────────────────────────────── + private static string Open(WLZonePickupSettings s, float now) + { + s_usedOriginalUi = false; + string how; + + if (s.preferOriginalUi && TryOpenOriginal(s)) { s_usedOriginalUi = true; how = "원본 SelectSkillUI"; } + else if (OpenFallback(s)) how = "런타임 패널"; + else + { + GiveUpCount++; + s_phase = ZonePickupPhase.Idle; + s_pendingZone = -1; + if (s.verboseLog) Debug.Log("[ZonePickup] 카드를 만들 수 없어 이번 픽업을 건너뛴다"); + return "열기 실패"; + } + + s_phase = ZonePickupPhase.Open; + s_openAt = now; + s_shownSec = int.MinValue; + OpenCount++; + PauseTimer(s, true); + HoldWorld(s, true); + StopActors(s, true); + WriteTimer(s, s.pickTimeoutSeconds); + if (s.verboseLog) Debug.Log("[ZonePickup] 카드 열림(" + how + ") — 존 " + s_pendingZone); + LastLog = how; + return "열림 — " + how; + } + + /// 원본 `SelectSkillUI` 노드가 살아 있으면 그대로 연다(원본 API 만 · 수정 0줄). + private static bool TryOpenOriginal(WLZonePickupSettings s) + { + var ui = OriginalUi(); + if (ui == null) return false; + try + { + ui.Set(null); // 원본 열기 API — 카드 뽑기·All_Stop(true)·자기 타이머까지 원본 그대로 + return ui.gameObject.activeInHierarchy; + } + catch (System.Exception ex) + { + if (s.verboseLog) Debug.Log("[ZonePickup] 원본 SelectSkillUI.Set 예외 — " + ex.Message); + return false; + } + } + + private static SelectSkillUI OriginalUi() + { + var g = GameUI.Ins; + if (g == null) return null; + var bundle = g.IngameUI; + if (bundle == null) return null; + var ui = bundle.m_SelectSkillUI; + return ui != null ? ui : null; + } + + private static bool OriginalUiActive() + { + var ui = OriginalUi(); + return ui != null && ui.gameObject.activeInHierarchy; + } + + private static int OriginalCardCount() + { + var ui = OriginalUi(); + return ui != null && ui.arr_SelectSkillCard != null ? ui.arr_SelectSkillCard.Length : 0; + } + + /// 폴백 = 원본 데이터·효과 경로 + 런타임 uGUI 표시. + private static bool OpenFallback(WLZonePickupSettings s) + { + if (s_root == null) return false; + if (!DrawCards(s)) return false; + EnsureCards(s); + ApplyLayout(); + FillCards(s); + if (s_group != null) { s_group.alpha = 1f; s_group.blocksRaycasts = true; s_group.interactable = true; } + s_root.gameObject.SetActive(true); + s_root.SetAsLastSibling(); + return true; + } + + /// 원본 뽑기 그대로 — `table_selectskill.Get_DataList(n)`(셔플·보유 제외·PreSkill 규칙). + private static bool DrawCards(WLZonePickupSettings s) + { + s_ids.Clear(); + s_rows.Clear(); + var t = table_selectskill.Ins; + if (t == null || InGameInfo.Ins == null) return false; + int want = Mathf.Max(1, s.cardCount); + try + { + var list = t.Get_DataList(want); + if (list == null || list.Count < want) return false; + for (int i = 0; i < list.Count; i++) + { + var row = list[i] as SelectSkillTableData; + if (row == null) return false; + s_ids.Add(row.ID); + s_rows.Add(row); + } + return true; + } + catch (System.Exception ex) + { + if (s.verboseLog) Debug.Log("[ZonePickup] 카드 뽑기 실패 — " + ex.Message); + s_ids.Clear(); + s_rows.Clear(); + return false; + } + } + + // ── 선택 · 닫기 ─────────────────────────────────────────────────────── + /// 카드 i 를 고른다. 효과 적용은 원본 경로 그대로. + public static string Pick(int index, bool auto) + { + var s = St; + if (s_phase != ZonePickupPhase.Open) return "열려 있지 않음"; + + PickCount++; + s_picksThisRun++; + + if (s_usedOriginalUi) + { + var ui = OriginalUi(); + if (ui != null && ui.arr_SelectSkillCard != null && ui.arr_SelectSkillCard.Length > 0) + { + int i = Mathf.Clamp(index, 0, ui.arr_SelectSkillCard.Length - 1); + var card = ui.arr_SelectSkillCard[i]; + // 원본 클릭 경로와 완전히 같은 줄(SelectSkillCard.OnClick_SelectSkill → SkillSelect). + if (card != null) { try { card.OnClick_SelectSkill(); } catch (System.Exception) { } } + } + Close(auto ? "자동 선택(원본)" : "선택(원본)"); + return "선택 " + index + (auto ? " (자동)" : ""); + } + + int id = index >= 0 && index < s_ids.Count ? s_ids[index] : 0; + if (id != 0) + { + var info = InGameInfo.Ins; + // 원본 SelectSkillUI.SkillSelect 59행과 같은 줄 = 효과 적용은 원본 로직 그대로. + if (info != null) { try { info.Add_SelectSkill(id); } catch (System.Exception ex) { if (s != null && s.verboseLog) Debug.Log("[ZonePickup] Add_SelectSkill 예외 — " + ex.Message); } } + } + if (s != null && s.verboseLog) Debug.Log("[ZonePickup] 카드 선택 " + index + " id=" + id + (auto ? " (자동)" : "")); + Close(auto ? "자동 선택" : "선택"); + return "선택 " + index + " id=" + id + (auto ? " (자동)" : ""); + } + + private static void OnCard0() { Pick(0, false); } + private static void OnCard1() { Pick(1, false); } + private static void OnCard2() { Pick(2, false); } + private static void OnCard3() { Pick(3, false); } + private static void OnCard4() { Pick(4, false); } + private static readonly UnityEngine.Events.UnityAction[] s_cardListeners = + { + OnCard0, OnCard1, OnCard2, OnCard3, OnCard4, + }; + + /// 닫기 — 정지 3종을 전부 되돌린다(원본 All_Stop(false) 포함). + public static string Close(string reason) + { + var s = St; + bool wasOpen = s_phase == ZonePickupPhase.Open; + s_phase = ZonePickupPhase.Idle; + s_pendingZone = -1; + s_shownSec = int.MinValue; + + Hide(); + StopActors(s, false); + HoldWorld(s, false); + PauseTimer(s, false); + s_usedOriginalUi = false; + LastLog = reason; + return (wasOpen ? "닫음 — " : "정리 — ") + reason; + } + + private static void Hide() + { + if (s_group != null) { s_group.alpha = 0f; s_group.blocksRaycasts = false; s_group.interactable = false; } + if (s_root != null) s_root.gameObject.SetActive(false); + } + + // ── 정지 3종 ────────────────────────────────────────────────────────── + private static void PauseTimer(WLZonePickupSettings s, bool on) + { + bool want = on && s != null && s.pauseRunTimer; + if (want == s_timerPaused) return; + s_timerPaused = want; + PauseCalls++; + RunDirector.PauseTimer(want); // 813p ≤10줄 추가분 — 세계 정지 없이 런 타이머만 + } + + private static void HoldWorld(WLZonePickupSettings s, bool on) + { + bool want = on && s != null && s.holdWorld; + if (want == s_worldHeld) return; + if (want) + { + // 813z 실측: 세계 정지 층은 TimeScaleArbiter 가 아니라 timeScale 직접 대입이다. + // 부활(813i/813z)이 이미 세계를 잡고 있으면 우리는 손대지 않는다(부활 우선). + if (DeathFlow.WorldHeld) return; + s_prevTimeScale = Time.timeScale; + Time.timeScale = s.holdWorldTimeScale; + s_worldHeld = true; + } + else + { + s_worldHeld = false; + if (!DeathFlow.WorldHeld) Time.timeScale = s_prevTimeScale > 0f ? s_prevTimeScale : 1f; + } + } + + private static void StopActors(WLZonePickupSettings s, bool on) + { + bool want = on && s != null && s.stopActorsWhileOpen; + if (want == s_actorsStopped) return; + var info = ActorInfo.Ins; + if (info == null) { s_actorsStopped = false; return; } + // 원본 SelectSkillUI 29·61행과 같은 줄. + try { info.All_Stop(want); s_actorsStopped = want; } + catch (System.Exception) { s_actorsStopped = false; } + } + + // ── 대기 조건 ───────────────────────────────────────────────────────── + private static bool ReviveBusy() + { + return ReviveDialog.Busy || DeathFlow.WorldHeld; + } + + private static bool GuideTravelling() + { + return RunGuide.Active && RunGuide.State == RunGuideState.Travel; + } + + /// 이 축이 쓰는 시계 — `timeoutUnscaled` 가 꺼져 있을 때만 scaled. 러너·핸들러가 같은 값을 쓴다. + public static float NowClock() + { + var s = St; + return s != null && !s.timeoutUnscaled ? Time.time : Time.unscaledTime; + } + + // ── 표시 ────────────────────────────────────────────────────────────── + /// 남은 초 — 표시값이 바뀐 초에만 쓴다(같은 초를 100번 틱해도 SetText 0회 = GC 0). + private static void WriteTimer(WLZonePickupSettings s, float remaining) + { + if (s_timer == null || s == null) return; + int t = remaining <= 0f ? 0 : Mathf.CeilToInt(remaining); + bool warn = s.timerWarnSeconds > 0f && remaining <= s.timerWarnSeconds; + if (t == s_shownSec && warn == s_shownWarn) return; + s_shownSec = t; + if (warn != s_shownWarn) { s_shownWarn = warn; s_timer.color = warn ? s.timerWarnColor : s.timerColor; } + s_timer.SetText(s.timerFormat, t); // TMP 무할당 오버로드({0} = float) + TimerWrites++; + } + + private static void FillCards(WLZonePickupSettings s) + { + for (int i = 0; i < s_card.Length; i++) + { + bool used = i < s_rows.Count; + if (s_card[i] != null) s_card[i].gameObject.SetActive(used); + if (!used) continue; + var row = s_rows[i] as SelectSkillTableData; + if (row == null) continue; + if (s_cardName[i] != null) s_cardName[i].SetText(row.Name); + if (s_cardDesc[i] != null) s_cardDesc[i].SetText(row.Desc); + if (s_cardBg[i] != null) s_cardBg[i].color = row.PetSkill ? s.cardPetBgColor : s.cardBgColor; + } + if (s_title != null) s_title.SetText(s.titleText); + } + + // ── 조립 ────────────────────────────────────────────────────────────── + public static string Bind(Transform uiRoot) + { + var s = St; + if (s == null) return "WLZonePickupSettings 에셋 없음"; + if (!WLZonePickupSettings.Enabled) return "off — 조립 생략"; + if (uiRoot == null) return "uiRoot=null"; + if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name; + + s_uiRoot = uiRoot; + var prt = WLVignetteUtil.FindUiPath(uiRoot, s.parentPath) as RectTransform; + if (prt == null) return "부모 없음 — 경로 \"" + s.parentPath + "\""; + if (s_font == null) s_font = RunHud.BorrowFont(uiRoot); + + if (s_root == null || s_root.parent != prt) + { + var go = new GameObject("WL_ZonePickup", typeof(RectTransform)); + go.layer = WLVignetteUtil.UILayer; + go.hideFlags = HideFlags.DontSave; + s_root = (RectTransform)go.transform; + s_root.SetParent(prt, false); + s_group = go.AddComponent(); + + var dimRt = WLVignetteUtil.NewChild(s_root, "Dim"); + WLVignetteUtil.Stretch(dimRt); + s_dim = dimRt.GetComponent(); + if (s_dim == null) s_dim = dimRt.gameObject.AddComponent(); + + s_box = WLVignetteUtil.NewChild(s_root, "Box"); + s_boxBg = s_box.GetComponent(); + if (s_boxBg == null) s_boxBg = s_box.gameObject.AddComponent(); + s_boxBg.raycastTarget = true; + + s_title = WLTextFxUtil.NewText(s_box, "Title", s_font, TextAlignmentOptions.Center); + s_timer = WLTextFxUtil.NewText(s_box, "Timer", s_font, TextAlignmentOptions.Center); + s_cardRoot = WLTextFxUtil.NewChild(s_box, "Cards"); + } + EnsureCards(s); + string layout = ApplyLayout(); + Hide(); + return "연결 " + s.parentPath + " · " + layout; + } + + /// 카드 노드를 설정 수만큼 만든다(줄이지 않는다 · 재사용 · 상한 5). + public static bool EnsureCards(WLZonePickupSettings s) + { + if (s_cardRoot == null) return false; + int want = Mathf.Clamp(s != null ? s.cardCount : 3, 1, s_cardListeners.Length); + if (s_card.Length >= want) return false; + + int old = s_card.Length; + System.Array.Resize(ref s_card, want); + System.Array.Resize(ref s_cardBg, want); + System.Array.Resize(ref s_cardName, want); + System.Array.Resize(ref s_cardDesc, want); + System.Array.Resize(ref s_cardButton, want); + for (int i = old; i < want; i++) + { + var rt = WLVignetteUtil.NewChild(s_cardRoot, "Card" + i); + s_card[i] = rt; + var bg = rt.GetComponent(); + if (bg == null) bg = rt.gameObject.AddComponent(); + bg.raycastTarget = true; + s_cardBg[i] = bg; + var b = rt.GetComponent