// ───────────────────────────────────────────────────────────────────────────── // 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 Canvas s_canvas; // WL-813c2(D-29) — 그리는 순서를 값으로 고정 private static GraphicRaycaster s_raycaster; // overrideSorting 캔버스는 자기 레이캐스터가 있어야 눌린다 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; // ── WL-813c2 (Q6 D-29) — 부활 팝업이 카드를 덮는 동안 타임아웃 정지 ──── private static float s_pausedFor; // 부활 때문에 멈춰 있던 누적 시간(초) private static float s_lastTickNow = -1f;// 직전 틱 시각(정지 구간을 dt 로 적분한다) private static bool s_reviveHeld; // 지금 부활 때문에 멈춰 있는가 /// 이번에 뽑힌 카드의 스킬 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; /// WL-813c2(D-29) — 열린 카드의 타이머를 부활 때문에 멈춘/재개한 횟수와 누적 정지 시간(진단). public static int ReviveHoldCount, ReviveResumeCount, ReviveHoldTicks; 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; } } /// WL-813c2(D-29) — 지금 부활 팝업 때문에 카드 타이머가 멈춰 있는가. public static bool TimeoutHeldByRevive { get { return s_reviveHeld; } } /// WL-813c2(D-29) — 이번 카드에서 부활 때문에 멈춘 누적 시간(초). public static float PausedSeconds { get { return s_pausedFor; } } /// WL-813c2(D-29) — 카드 전용 Canvas 의 sortingOrder(없으면 −1). 부활 팝업(60)보다 작아야 한다. public static int SortingOrder { get { return s_canvas != null && s_canvas.enabled && s_canvas.overrideSorting ? s_canvas.sortingOrder : -1; } } 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 "원본이 닫음"; } // ── WL-813c2 (Q6 D-29) — 부활 팝업이 카드를 덮는 동안 타임아웃 정지 ────────────── // 813o 의 `deferWhileRevive` 는 **열기 전(Pending)** 에서만 본다(TickPending). 카드가 **먼저 열린 뒤** // 죽으면 부활 팝업(sortingOrder 60)이 카드를 덮은 채 그 밑에서 타이머가 계속 흘러 // 플레이어가 보지도 고르지도 못한 채 자동 선택된다(`R3_15` = 10초 → 2초 → 자동 선택). // → 열린 동안에도 부활 상태를 폴링해 **멈춘 시간만큼 마감을 뒤로 민다**(부활 뒤 남은 초부터 재개). // 🔴 GC 0 — 문자열은 상수만 돌려준다. 값·스위치는 전부 SO. float dt = s_lastTickNow >= 0f ? Mathf.Max(0f, now - s_lastTickNow) : 0f; s_lastTickNow = now; bool held = s.pauseTimeoutWhileRevive && ReviveBusy(); if (held) { s_pausedFor += dt; ReviveHoldTicks++; if (!s_reviveHeld) { s_reviveHeld = true; ReviveHoldCount++; } } else if (s_reviveHeld) { s_reviveHeld = false; ReviveResumeCount++; } float left = s.pickTimeoutSeconds - (now - s_openAt - s_pausedFor); WriteTimer(s, left); if (held) return "부활 우선 — 타이머 정지"; 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; s_pausedFor = 0f; s_lastTickNow = now; s_reviveHeld = false; // WL-813c2(D-29) 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; s_pausedFor = 0f; s_lastTickNow = -1f; s_reviveHeld = false; // WL-813c2(D-29) 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