Merge branch 'wl/ui/WL-813o-zone-pickup'

This commit is contained in:
깃 관리자 2026-09-09 13:07:07 +09:00
commit 6850ceb326
9 changed files with 1832 additions and 2 deletions

View File

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

View File

@ -0,0 +1,524 @@
// WL-813o 프로브 — 존 클리어 픽업 카드 실측 (에디트 모드 · Play 0 · 로그인 0).
// unity command run_script --file AgentScripts/WL813o_Probe.cs --entry WL813o_Probe.RunAll
// 산출물: <worktree>/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<SelectSkillUI>(true);
var cards = root.GetComponentsInChildren<SelectSkillCard>(true);
var gameUi = root.GetComponentsInChildren<GameUI>(true);
var nguiLabels = root.GetComponentsInChildren<UILabel>(true);
var nguiSprites = root.GetComponentsInChildren<UISprite>(true);
var tmp = root.GetComponentsInChildren<TextMeshProUGUI>(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<table_selectskill>();
tbl.m_json = AssetDatabase.LoadAssetAtPath<TextAsset>(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>();
InGameInfo.Ins = info; // Awake 는 무거워서 부르지 않는다(Ins 만 세운다)
Chk(InGameInfo.Ins != null, "InGameInfo.Ins 설정");
var actors = _fakeInfos.AddComponent<ActorInfo>();
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<SafeAreaFitter>(true) != null || hud.GetComponent<SafeAreaFitter>() != 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<ReviveDialog>(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); }
}
}

View File

@ -140,6 +140,15 @@ namespace WL.Combat.Run
}
}
/// <summary>외부 타이머 정지(813o 픽업 카드) — 세계 정지 없이 런 타이머만 멈춘다.</summary>
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; }
}

View File

@ -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
{
/// <summary>픽업 카드의 진행 단계.</summary>
public enum ZonePickupPhase
{
/// <summary>대기 — 예약 없음.</summary>
Idle = 0,
/// <summary>존 클리어를 받아 열기를 예약했다(지연·가이드·부활 대기).</summary>
Pending = 1,
/// <summary>카드가 열려 있다.</summary>
Open = 2,
}
/// <summary>존 클리어 픽업 카드(정적 · 노드는 런타임 생성 · 프리팹 diff 0).</summary>
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;
/// <summary>이번에 뽑힌 카드의 스킬 ID(0 = 빈 칸). 폴백 패널 경로에서만 채운다.</summary>
private static readonly List<int> s_ids = new List<int>(8);
private static readonly List<object> s_rows = new List<object>(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;
}
/// <summary>원본 `SelectSkillUI` 노드가 살아 있으면 그대로 연다(원본 API 만 · 수정 0줄).</summary>
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;
}
/// <summary>폴백 = 원본 데이터·효과 경로 + 런타임 uGUI 표시.</summary>
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;
}
/// <summary>원본 뽑기 그대로 — `table_selectskill.Get_DataList(n)`(셔플·보유 제외·PreSkill 규칙).</summary>
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;
}
}
// ── 선택 · 닫기 ───────────────────────────────────────────────────────
/// <summary>카드 i 를 고른다. 효과 적용은 원본 경로 그대로.</summary>
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,
};
/// <summary>닫기 — 정지 3종을 전부 되돌린다(원본 All_Stop(false) 포함).</summary>
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;
}
/// <summary>이 축이 쓰는 시계 — `timeoutUnscaled` 가 꺼져 있을 때만 scaled. 러너·핸들러가 같은 값을 쓴다.</summary>
public static float NowClock()
{
var s = St;
return s != null && !s.timeoutUnscaled ? Time.time : Time.unscaledTime;
}
// ── 표시 ──────────────────────────────────────────────────────────────
/// <summary>남은 초 — 표시값이 바뀐 초에만 쓴다(같은 초를 100번 틱해도 SetText 0회 = GC 0).</summary>
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<CanvasGroup>();
var dimRt = WLVignetteUtil.NewChild(s_root, "Dim");
WLVignetteUtil.Stretch(dimRt);
s_dim = dimRt.GetComponent<Image>();
if (s_dim == null) s_dim = dimRt.gameObject.AddComponent<Image>();
s_box = WLVignetteUtil.NewChild(s_root, "Box");
s_boxBg = s_box.GetComponent<Image>();
if (s_boxBg == null) s_boxBg = s_box.gameObject.AddComponent<Image>();
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;
}
/// <summary>카드 노드를 설정 수만큼 만든다(줄이지 않는다 · 재사용 · 상한 5).</summary>
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<Image>();
if (bg == null) bg = rt.gameObject.AddComponent<Image>();
bg.raycastTarget = true;
s_cardBg[i] = bg;
var b = rt.GetComponent<Button>();
if (b == null) b = rt.gameObject.AddComponent<Button>();
b.targetGraphic = bg;
b.onClick.RemoveListener(s_cardListeners[i]);
b.onClick.AddListener(s_cardListeners[i]);
s_cardButton[i] = b;
s_cardName[i] = WLTextFxUtil.NewText(rt, "Name", s_font, TextAlignmentOptions.Center);
s_cardDesc[i] = WLTextFxUtil.NewText(rt, "Desc", s_font, TextAlignmentOptions.Top);
}
return true;
}
public static string ApplyLayout()
{
var s = St;
if (s == null) return "WLZonePickupSettings 에셋 없음 — 배치 건너뜀";
if (s_root == null || s_box == null) return "노드 없음 — Bind 먼저";
var canvas = s_root.GetComponentInParent<Canvas>(true);
float u = s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null);
WLVignetteUtil.Stretch(s_root);
s_root.localScale = Vector3.one;
if (s_dim != null) { s_dim.color = s.dimColor; s_dim.raycastTarget = s.dimBlocksTouch; }
s_box.anchorMin = s_box.anchorMax = new Vector2(0.5f, 0.5f);
s_box.pivot = new Vector2(0.5f, 0.5f);
s_box.anchoredPosition = new Vector2(0f, s.boxCenterOffsetYPx * u);
s_box.sizeDelta = new Vector2(s.boxSizePx.x * u, s.boxSizePx.y * u);
s_box.localScale = Vector3.one;
if (s_boxBg != null) s_boxBg.color = s.boxColor;
float y = s.padTopPx * u;
if (s_title != null)
{
TopRow(s_title.rectTransform, s, u, y, s.titleFontPx * u);
s_title.fontSize = s.titleFontPx * u;
s_title.color = s.titleColor;
s_title.alignment = TextAlignmentOptions.Center;
WLTextFxUtil.ApplyEdge(s_title, s.edge);
}
y += (s.titleFontPx + s.titleGapPx) * u;
if (s_timer != null)
{
TopRow(s_timer.rectTransform, s, u, y, s.timerFontPx * u);
s_timer.fontSize = s.timerFontPx * u;
s_timer.color = s_shownWarn ? s.timerWarnColor : s.timerColor;
s_timer.alignment = TextAlignmentOptions.Center;
WLTextFxUtil.ApplyEdge(s_timer, s.edge);
}
y += (s.timerFontPx + s.timerGapPx) * u;
int n = s_card.Length;
float cw = s.cardSizePx.x * u, ch = s.cardSizePx.y * u, gap = s.cardGapPx * u;
float total = n * cw + (n - 1) * gap;
if (s_cardRoot != null)
{
s_cardRoot.anchorMin = new Vector2(0.5f, 1f); s_cardRoot.anchorMax = new Vector2(0.5f, 1f);
s_cardRoot.pivot = new Vector2(0.5f, 1f);
s_cardRoot.anchoredPosition = new Vector2(0f, -y);
s_cardRoot.sizeDelta = new Vector2(total, ch);
s_cardRoot.localScale = Vector3.one;
}
for (int i = 0; i < n; i++)
{
var rt = s_card[i];
if (rt == null) continue;
rt.anchorMin = new Vector2(0.5f, 1f); rt.anchorMax = new Vector2(0.5f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.sizeDelta = new Vector2(cw, ch);
rt.anchoredPosition = new Vector2(-total * 0.5f + cw * 0.5f + i * (cw + gap), 0f);
rt.localScale = Vector3.one;
float pad = s.cardPadPx * u;
var nm = s_cardName[i];
if (nm != null)
{
var nrt = nm.rectTransform;
nrt.anchorMin = new Vector2(0f, 1f); nrt.anchorMax = new Vector2(1f, 1f);
nrt.pivot = new Vector2(0.5f, 1f);
nrt.offsetMin = new Vector2(pad, 0f); nrt.offsetMax = new Vector2(-pad, 0f);
nrt.anchoredPosition = new Vector2(0f, -pad);
nrt.sizeDelta = new Vector2(0f, s.cardNameHeightPx * u);
nm.fontSize = s.cardNameFontPx * u;
nm.color = s.cardNameColor;
nm.alignment = TextAlignmentOptions.Center;
nm.textWrappingMode = TextWrappingModes.Normal;
WLTextFxUtil.ApplyEdge(nm, s.edge);
}
var de = s_cardDesc[i];
if (de != null)
{
var drt = de.rectTransform;
drt.anchorMin = new Vector2(0f, 0f); drt.anchorMax = new Vector2(1f, 1f);
drt.pivot = new Vector2(0.5f, 0.5f);
drt.offsetMin = new Vector2(pad, pad);
drt.offsetMax = new Vector2(-pad, -(pad + s.cardNameHeightPx * u));
de.fontSize = s.cardDescFontPx * u;
de.color = s.cardDescColor;
de.alignment = TextAlignmentOptions.Top;
de.textWrappingMode = TextWrappingModes.Normal;
de.overflowMode = TextOverflowModes.Ellipsis;
WLTextFxUtil.ApplyEdge(de, s.edge);
}
}
return "box" + s_box.sizeDelta + " 카드=" + n + " unitsPerPx=" + u.ToString("F4");
}
private static void TopRow(RectTransform rt, WLZonePickupSettings s, float u, float top, float h)
{
rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(s.padXPx * u, 0f); rt.offsetMax = new Vector2(-s.padXPx * u, 0f);
rt.anchoredPosition = new Vector2(0f, -top);
rt.sizeDelta = new Vector2(0f, h);
rt.localScale = Vector3.one;
}
public static void SetFont(TMP_FontAsset f)
{
s_font = f;
for (int i = 0; i < s_cardName.Length; i++)
{
if (s_cardName[i] != null) s_cardName[i].font = f;
if (s_cardDesc[i] != null) s_cardDesc[i].font = f;
}
if (s_title != null) s_title.font = f;
if (s_timer != null) s_timer.font = f;
}
// ── 진단 · 정리 (프로브 전용 · 게임 코드는 부르지 않는다) ─────────────
public static string ResetState()
{
Close("리셋");
ZoneClearedSeen = OpenCount = PickCount = TimeoutCount = SkipCount = 0;
GuideWaitCount = ReviveDeferCount = GiveUpCount = TimerWrites = PauseCalls = 0;
s_picksThisRun = 0;
LastLog = ""; LastSkipReason = "";
return "리셋";
}
public static string Teardown()
{
Subscribe(false);
Close("teardown");
if (s_root != null) Object.DestroyImmediate(s_root.gameObject);
s_root = null; s_group = null; s_dim = null; s_box = null; s_boxBg = null;
s_title = null; s_timer = null; s_cardRoot = null; s_uiRoot = null;
s_card = new RectTransform[0]; s_cardBg = new Image[0];
s_cardName = new TextMeshProUGUI[0]; s_cardDesc = new TextMeshProUGUI[0];
s_cardButton = new Button[0];
return "정리 완료";
}
public static string Dump()
{
return "phase=" + s_phase + " open=" + IsOpen + " zone=" + s_pendingZone +
" picks=" + s_picksThisRun + " cards=" + s_ids.Count +
" sec=" + s_shownSec + " timerPaused=" + s_timerPaused +
" worldHeld=" + s_worldHeld + " actorsStopped=" + s_actorsStopped +
" orig=" + s_usedOriginalUi + " bound=" + Bound;
}
/// <summary>프로브·QA 전용 — 카드 버튼을 실제 클릭 경로로 누른다.</summary>
public static string ClickCard(int i)
{
if (i < 0 || i >= s_cardButton.Length || s_cardButton[i] == null) return "카드 버튼 없음 " + i;
s_cardButton[i].onClick.Invoke();
return "클릭 " + i;
}
}
/// <summary>
/// 검증 전용 훅 — `CombatEventList&lt;T&gt;.Dispatch` 가 internal 이라 Assets 밖 프로브가 부를 수 없다.
/// 813g/813y `LootToast.RaiseFakeKilled()` · 813q `WLRunUiProbeHooks` 선례.
/// 🔴 게임 코드는 이 클래스를 한 번도 부르지 않는다(전부 void = GC 측정 오염 0).
/// </summary>
public static class WLZonePickupProbeHooks
{
public static void RaiseZoneCleared(int zoneIndex, int spawnerId, float elapsedSec,
int kills, int required, bool allZonesCleared)
{
var e = new ZoneClearedEvent
{
zoneIndex = zoneIndex, spawnerId = spawnerId, elapsedSec = elapsedSec,
kills = kills, required = required, allZonesCleared = allZonesCleared,
time = Time.unscaledTime, frame = Time.frameCount,
};
RunEvents.ZoneCleared.Dispatch(in e);
}
}
/// <summary>픽업 카드를 매 프레임 돌리는 런타임 전용 러너(씬에 굽지 않는다).</summary>
internal sealed class WLZonePickupRunner : MonoBehaviour
{
private static WLZonePickupRunner s_ins;
private NewGameUI _lastUi;
internal static void Ensure()
{
if (s_ins != null) return;
var go = new GameObject("WL_ZonePickupRunner");
go.hideFlags = HideFlags.HideAndDontSave;
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<WLZonePickupRunner>();
}
private void Update()
{
var ui = NewGameUI.Ins;
if (ui != null && !ReferenceEquals(ui, _lastUi))
{
_lastUi = ui;
ZonePickup.Bind(ui.transform);
}
ZonePickup.Tick(ZonePickup.NowClock());
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc48ff0df7dc434ab668f9c11c4caa70

View File

@ -0,0 +1,69 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2b737e6d9b14f34b7fbad42597e179e, type: 3}
m_Name: WLZonePickupSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLZonePickupSettings
enabled_: 1
verboseLog: 0
includeFinalZone: 0
minZoneIndex: 0
maxPicksPerRun: 0
openDelaySeconds: 0.35
waitForGuideArrival: 1
maxGuideDelaySeconds: 8
deferWhileRevive: 1
maxDeferSeconds: 45
cardCount: 3
preferOriginalUi: 1
pickTimeoutSeconds: 10
timeoutUnscaled: 1
autoPickFirstCard: 1
timerFormat: "{0}초 후 자동 선택"
timerWarnSeconds: 3
pauseRunTimer: 1
holdWorld: 0
holdWorldTimeScale: 0
stopActorsWhileOpen: 1
parentPath: IngameUIs/WL_HUD
dimColor: {r: 0, g: 0, b: 0, a: 0.62}
dimBlocksTouch: 1
boxSizePx: {x: 980, y: 720}
boxCenterOffsetYPx: 40
boxColor: {r: 0.043, g: 0.047, b: 0.071, a: 0.92}
padXPx: 28
padTopPx: 26
titleText: "스킬 선택"
titleFontPx: 54
titleColor: {r: 0.949, g: 0.961, b: 0.98, a: 1}
titleGapPx: 14
timerFontPx: 36
timerColor: {r: 0.78, g: 0.82, b: 0.88, a: 1}
timerWarnColor: {r: 1, g: 0.29, b: 0.28, a: 1}
timerGapPx: 16
cardSizePx: {x: 288, y: 430}
cardGapPx: 22
cardBgColor: {r: 0.12, g: 0.13, b: 0.18, a: 1}
cardPetBgColor: {r: 0.2, g: 0.13, b: 0.22, a: 1}
cardNameFontPx: 38
cardNameColor: {r: 1, g: 0.86, b: 0.45, a: 1}
cardDescFontPx: 26
cardDescColor: {r: 0.86, g: 0.89, b: 0.94, a: 1}
cardPadPx: 16
cardNameHeightPx: 84
edge:
enabled: 1
outlineWidth: 0.26
outlineColor: {r: 0.03, g: 0.03, b: 0.05, a: 1}
shadowOffset: {x: 0.55, y: -0.55}
shadowColor: {r: 0, g: 0, b: 0, a: 0.72}
shadowSoftness: 0.28
faceDilate: 0.06

View File

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

View File

@ -0,0 +1,248 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLZonePickupSettings.cs — 존 클리어 픽업 카드 값의 단일 출처 (WL-813o · #813)
//
// 기준서 §D-2 813o 행 + 기획안 F-3 + 발주서 WL-813o §1(Lead 설계 결정).
// 트리거 계약 = 813p 완료보고 §4 `RunEvents.ZoneCleared`(`Assets/WL/Combat/Run/RunEvents.cs` · 읽기만).
//
// ■ 왜 이 에셋인가 (C45)
// 타임아웃 초·카드 수·존 포함 범위·배치·색·문구를 코드 상수로 두지 않는다. 이 파일에는
// "필드와 기본값"만 있고, 게임이 읽는 값은 `Resources/WL/WLZonePickupSettings.asset` 이다.
// 🔴 런 길이·존 수 같은 **게임 값은 여기 없다** — 813p `WLRunSettings` 소유이고
// 픽업은 `ZoneCleared` 페이로드와 `RunDirector.ZoneCount` 로 받는다(수치 중복 0).
//
// ■ 좌표계 (px)
// 813c/813q 와 같은 **세로 1080×1920 기준 화면 픽셀**. px → 캔버스 유닛 환산은
// `WLHudLayoutSettings.UnitsPerPx(canvas)` **하나만** 쓴다(산식 중복 0).
//
// ■ C8 롤백
// `enabled_ = 0` → 구독 0 · 노드 0 · 표시 0 · 런 타이머 정지 0 = 813o 이전 동작 100%.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.UI
{
[CreateAssetMenu(fileName = "WLZonePickupSettings", menuName = "WL/Zone Pickup Settings", order = 34)]
public sealed class WLZonePickupSettings : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLZonePickupSettings";
private static WLZonePickupSettings s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 전부 건너뛴다 = C8 롤백).</summary>
public static WLZonePickupSettings Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLZonePickupSettings>(ResourcesPath);
}
return s_instance;
}
}
/// <summary>에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다.</summary>
public static void ClearCache() { s_instance = null; s_tried = false; }
/// <summary>프로브·QA 용 런타임 스위치(에셋을 건드리지 않고 끈다).</summary>
public static bool RuntimeDisabled;
/// <summary>픽업 축이 살아 있는가 = 에셋 존재 · enabled_ · 런타임 스위치 off.</summary>
public static bool Enabled
{
get
{
if (RuntimeDisabled) return false;
var s = Instance;
return s != null && s.enabled_;
}
}
// ── 공통 ──────────────────────────────────────────────────────────────
[Header("공통 (C8 롤백)")]
[Tooltip("끄면 구독 0 · 노드 0 · 표시 0 · 런 타이머 정지 0 = 813o 이전 동작 100%.")]
public bool enabled_ = true;
[Tooltip("켜면 상태 전이마다 Console 에 1줄 남긴다(문자열을 만들기 전에 이 값으로 막는다 · GC 주의).")]
public bool verboseLog = false;
// ── 트리거 ────────────────────────────────────────────────────────────
[Header("트리거 — 813p RunEvents.ZoneCleared")]
[Tooltip("존 4(마지막 존)에서도 카드를 띄울지. 기본 끔 = 보스 흐름 방해 금지(발주서 §1-1).")]
public bool includeFinalZone = false;
[Tooltip("이 인덱스(0부터) 이상인 존만 카드를 띄운다. 0 = 존1부터 전부.")]
public int minZoneIndex = 0;
[Tooltip("런 1회당 최대 카드 횟수. 0 = 무제한(존 수만큼).")]
public int maxPicksPerRun = 0;
[Tooltip("ZoneCleared 를 받고 실제로 카드를 열기까지의 지연(초 · unscaled). 존 클리어 연출과 겹치지 않게 약간 둔다.")]
public float openDelaySeconds = 0.35f;
[Tooltip("813w RunGuide 가 다음 존으로 이동 중(Travel)이면 도착한 뒤에 연다.")]
public bool waitForGuideArrival = true;
[Tooltip("가이드 이동을 기다리는 최대 시간(초 · unscaled). 넘으면 이동 중이어도 연다(발주서 §1-1 = 8s).")]
public float maxGuideDelaySeconds = 8f;
[Tooltip("813i/813z 부활 팝업이 떠 있거나 예약 중이면 부활을 우선하고 카드를 미룬다.")]
public bool deferWhileRevive = true;
[Tooltip("부활 때문에 미룰 수 있는 최대 시간(초 · unscaled). 넘으면 이번 픽업을 버린다(무한 대기 금지).")]
public float maxDeferSeconds = 45f;
// ── 카드 ──────────────────────────────────────────────────────────────
[Header("카드 — 원본 table_selectskill / InGameInfo.Add_SelectSkill")]
[Tooltip("한 번에 보여 줄 카드 수(기획안 F-3 = 3장 중 1장).")]
public int cardCount = 3;
[Tooltip("원본 SelectSkillUI 노드(GameUI.Ins.IngameUI.m_SelectSkillUI)가 있으면 그걸 연다. " +
"없으면 같은 원본 데이터·효과 경로를 쓰는 런타임 패널로 폴백한다. " +
"🔴 실측(2026-09-09): 그 노드는 어떤 씬/프리팹에도 없다 = 항상 폴백.")]
public bool preferOriginalUi = true;
// ── 타임아웃 ──────────────────────────────────────────────────────────
[Header("타임아웃 — 자동 선택")]
[Tooltip("카드가 열린 뒤 자동 선택까지의 시간(초). 발주서 §1-2 = 10s.")]
public float pickTimeoutSeconds = 10f;
[Tooltip("타임아웃을 unscaled 시간으로 잰다(timeScale 0 이어도 흐른다).")]
public bool timeoutUnscaled = true;
[Tooltip("타임아웃 시 첫 카드를 고른다(발주서 §1-2). 끄면 원본 방식대로 무작위 카드.")]
public bool autoPickFirstCard = true;
[Tooltip("남은 초 표시 문구. TMP 무할당 오버로드 SetText(format, float) 를 쓴다 — {0} 하나만 넣을 것.")]
public string timerFormat = "{0}초 후 자동 선택";
[Tooltip("남은 초가 이 값 이하면 경고색. 0 이면 경고색 없음.")]
public float timerWarnSeconds = 3f;
// ── 정지 ──────────────────────────────────────────────────────────────
[Header("정지 — 런 타이머 / 자동전투")]
[Tooltip("카드가 열려 있는 동안 813p 런 타이머를 멈춘다(RunDirector.PauseTimer · 세계 정지 없음).")]
public bool pauseRunTimer = true;
[Tooltip("카드가 열려 있는 동안 timeScale 을 0 으로 세운다(813z DeathFlow.HoldWorld 방식). " +
"기본 끔 = 전투는 계속(발주서 §1-3 권장). 🔴 켜면 부활 팝업과 정지 주체가 겹친다.")]
public bool holdWorld = false;
[Tooltip("holdWorld 가 켜졌을 때 세울 timeScale 값.")]
public float holdWorldTimeScale = 0f;
[Tooltip("카드가 열려 있는 동안 원본 ActorInfo.Ins.All_Stop(true) 로 액터를 멈춘다 " +
"(= 원본 SelectSkillUI.Set/SkillSelect 가 하던 그 경로). 선택 즉시 All_Stop(false) 로 되돌린다.")]
public bool stopActorsWhileOpen = true;
// ── 배치 ──────────────────────────────────────────────────────────────
[Header("배치 (세로 1080×1920 기준 px)")]
[Tooltip("패널을 붙일 부모 노드의 UI 루트 기준 경로(813y/813q 실측 = IngameUIs/WL_HUD · SafeAreaFitter 아래).")]
public string parentPath = "IngameUIs/WL_HUD";
[Tooltip("배경 어둡게(딤) 색.")]
public Color dimColor = new Color(0f, 0f, 0f, 0.62f);
[Tooltip("딤이 터치를 먹는다(카드 밖을 눌러도 아무 일도 없게).")]
public bool dimBlocksTouch = true;
[Tooltip("카드 묶음 상자 크기(px).")]
public Vector2 boxSizePx = new Vector2(980f, 720f);
[Tooltip("상자 중심의 세로 오프셋(px · 양수 = 위). 0 = 화면 중앙.")]
public float boxCenterOffsetYPx = 40f;
[Tooltip("상자 배경색.")]
public Color boxColor = new Color(0.043f, 0.047f, 0.071f, 0.92f);
[Tooltip("상자 좌우 안쪽 여백(px).")]
public float padXPx = 28f;
[Tooltip("상자 위쪽 안쪽 여백(px).")]
public float padTopPx = 26f;
[Header("제목 · 남은 초")]
[Tooltip("제목 문구.")]
public string titleText = "스킬 선택";
[Tooltip("제목 글자 크기(px).")]
public float titleFontPx = 54f;
[Tooltip("제목 색.")]
public Color titleColor = new Color(0.949f, 0.961f, 0.98f, 1f);
[Tooltip("제목 아래 간격(px).")]
public float titleGapPx = 14f;
[Tooltip("남은 초 글자 크기(px).")]
public float timerFontPx = 36f;
[Tooltip("남은 초 색.")]
public Color timerColor = new Color(0.78f, 0.82f, 0.88f, 1f);
[Tooltip("남은 초 경고색(timerWarnSeconds 이하).")]
public Color timerWarnColor = new Color(1f, 0.29f, 0.28f, 1f);
[Tooltip("남은 초 줄 아래 간격(px).")]
public float timerGapPx = 16f;
[Header("카드 모양")]
[Tooltip("카드 1장 크기(px).")]
public Vector2 cardSizePx = new Vector2(288f, 430f);
[Tooltip("카드 사이 간격(px).")]
public float cardGapPx = 22f;
[Tooltip("카드 배경색.")]
public Color cardBgColor = new Color(0.12f, 0.13f, 0.18f, 1f);
[Tooltip("펫 스킬 카드 배경색(원본 SelectSkillCard 의 petreinforce_bg 대응).")]
public Color cardPetBgColor = new Color(0.20f, 0.13f, 0.22f, 1f);
[Tooltip("카드 이름 글자 크기(px).")]
public float cardNameFontPx = 38f;
[Tooltip("카드 이름 색.")]
public Color cardNameColor = new Color(1f, 0.86f, 0.45f, 1f);
[Tooltip("카드 설명 글자 크기(px).")]
public float cardDescFontPx = 26f;
[Tooltip("카드 설명 색.")]
public Color cardDescColor = new Color(0.86f, 0.89f, 0.94f, 1f);
[Tooltip("카드 안쪽 여백(px).")]
public float cardPadPx = 16f;
[Tooltip("카드 이름 줄 높이(px).")]
public float cardNameHeightPx = 84f;
// ── 텍스트 테두리 (813y WLTextFxUtil.ApplyEdge) ────────────────────────
[Header("텍스트 테두리 (813y ApplyEdge · TMP 머티리얼 아웃라인)")]
[Tooltip("제목·남은 초·카드 글자에 씌울 아웃라인/그림자.")]
public WLTextEdge edge = new WLTextEdge
{
enabled = true,
outlineWidth = 0.26f,
outlineColor = new Color(0.03f, 0.03f, 0.05f, 1f),
shadowOffset = new Vector2(0.55f, -0.55f),
shadowColor = new Color(0f, 0f, 0f, 0.72f),
shadowSoftness = 0.28f,
faceDilate = 0.06f,
};
// ── 환산 ──────────────────────────────────────────────────────────────
/// <summary>px → 캔버스 유닛. 813c `WLHudLayoutSettings` 의 산식 하나만 쓴다(중복 0).</summary>
public float UnitsPerPx(Canvas canvas)
{
var hud = WLHudLayoutSettings.Instance;
return hud != null ? hud.UnitsPerPx(canvas) : 1f;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2b737e6d9b14f34b7fbad42597e179e