// ─────────────────────────────────────────────────────────────────────────────
// 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,
}
// ─────────────────────────────────────────────────────────────────────────
// WL-813o2 (Q9 D-41) — 카드 풀 검증
//
// ■ 왜
// 「별」(ID 14 · `Select_Star`)은 `Select_Star.cs:27` 에서 0.05 s 마다 32방향으로
// `Shoot_Projectile("ProjectileStar", …)` 를 부른다. 그런데
// `Assets/Res_Addr/Projectile/ProjectileStar.prefab` 이 **디스크에 없다** →
// `InvalidKeyException` **초당 20건 · 845건/런**(Q9 §7) → 결과창을 덮는 모달(D-42).
//
// ■ 키를 어떻게 얻나 (전부 원본 실측 · 원본 수정 0)
// 1차 키 = 원본 `InGameInfo.Get_SkillPrefabPath(eEffect)` 와 **같은 규칙**(형식 문자열은 SO).
// 단 원본 `Add_SelectSkill` 스위치가 프리팹을 로드하지 않는 효과는 1차 키가 없다
// (`noPrefabEffects` · 예: `Select_Heal` · `Select_Shield` · `Select_*2` 계열).
// 추가 키 = 효과 클래스가 **코드 문자열로** 요구하는 키(`extraKeys` · 예 `ProjectileStar` · `SwordSlash`
// · `Select_Shield` → `Skill_Shield`). 에디트 모드 프로브가 grep 으로 실측해 SO 에 넣는다.
//
// ■ 존재 검사
// 런타임 = `Addressables.ResourceLocators` 를 순회하며 `Locate(key, GameObject)`(동기 · 비동기 0).
// 카탈로그가 아직 없으면 **검사를 건너뛴다**(모르면 빼지 않는다).
// 에디터 = 카탈로그가 없을 때 `AssetDatabase` 로 대신 본다(에디트 모드 프로브가 이 경로를 쓴다).
//
// ■ GC — 판정은 **ID 당 1회**만 하고 결과를 캐시한다. 2번째 뽑기부터는 문자열 0 · 할당 0.
// ─────────────────────────────────────────────────────────────────────────
/// 선택스킬 행이 참조하는 프리팹 키가 실제로 있는지 본다(결과 캐시).
public static class ZonePickupPool
{
/// 검사한 행 수(누적 · 프로브가 읽는다).
public static int CheckedCount;
/// 부재 키 때문에 뺀 행 수(누적).
public static int MissingKeyCount;
/// SO `excludeSkillIds` 로 뺀 행 수(누적).
public static int StaticExcludeCount;
/// 카탈로그가 없어 키 검사를 건너뛴 횟수.
public static int SkippedCheckCount;
/// 마지막 제외 사유(진단용).
public static string LastReason = "";
/// 마지막으로 부재로 판정된 키.
public static string LastMissingKey = "";
/// ID → 사용 가능(true) / 제외(false). 판정 1회 뒤 재사용.
static readonly Dictionary s_ok = new Dictionary(64);
/// 제외된 ID → 사유(로그·프로브용).
static readonly Dictionary s_reason = new Dictionary(8);
/// 키 → 존재. 같은 키를 여러 행이 써도 1회만 본다.
static readonly Dictionary s_key = new Dictionary(64);
/// 이미 로그를 남긴 ID(같은 줄을 반복하지 않는다).
static readonly HashSet s_logged = new HashSet();
/// 프로브·런 경계용 — 캐시와 카운터를 비운다.
public static void ResetCache()
{
s_ok.Clear(); s_reason.Clear(); s_key.Clear(); s_logged.Clear();
CheckedCount = MissingKeyCount = StaticExcludeCount = SkippedCheckCount = 0;
LastReason = ""; LastMissingKey = "";
}
/// 이 ID 가 제외라면 사유, 아니면 "" (프로브가 읽는다).
public static string ReasonFor(int id)
{
string r;
return s_reason.TryGetValue(id, out r) ? r : "";
}
/// 검증 축이 살아 있는가(끄면 813o 동작 그대로).
public static bool Active(WLZonePickupSettings s) { return s != null && s.validatePool; }
/// 이 행을 뽑기에서 빼야 하는가. `reason` 은 로그·프로브용(뺄 때만 채운다).
public static bool Excluded(WLZonePickupSettings s, SelectSkillTableData row, out string reason)
{
reason = null;
if (row == null || !Active(s)) return false;
bool ok;
if (s_ok.TryGetValue(row.ID, out ok))
{
if (ok) return false;
reason = ReasonFor(row.ID);
return true;
}
CheckedCount++;
if (IsListed(s.excludeSkillIds, row.ID))
{
StaticExcludeCount++;
s_ok[row.ID] = false;
reason = "SO 제외 목록";
Remember(s, row.ID, reason, "");
return true;
}
string missing;
if (s.runtimeKeyCheck && !KeysExist(s, row, out missing))
{
MissingKeyCount++;
s_ok[row.ID] = false;
reason = "참조 키 부재";
LastMissingKey = missing;
Remember(s, row.ID, reason, missing);
return true;
}
s_ok[row.ID] = true;
return false;
}
/// 이 행이 요구하는 키가 전부 있는가. 없으면 `missing` 에 첫 부재 키.
public static bool KeysExist(WLZonePickupSettings s, SelectSkillTableData row, out string missing)
{
missing = "";
if (s == null || row == null) return true;
string effect = row.SkillEffect.ToString();
// 1차 키 — 원본 Get_SkillPrefabPath 와 같은 규칙(형식은 SO).
if (row.SkillEffect != eEffect.None && !IsListed(s.noPrefabEffects, effect))
{
string fmt = effect.Contains("Select") ? s.selectSkillPathFormat
: effect.Contains("Skill") ? s.skillPathFormat
: s.effectPathFormat;
if (!string.IsNullOrEmpty(fmt) && !KeyExists(string.Format(fmt, effect)))
{ missing = string.Format(fmt, effect); return false; }
}
// 추가 키 — 효과 클래스가 코드 문자열로 요구하는 것들.
var extras = s.extraKeys;
if (extras != null)
{
for (int i = 0; i < extras.Length; i++)
{
var x = extras[i];
if (x == null || string.IsNullOrEmpty(x.assetKey)) continue;
if (!string.Equals(x.skillEffect, effect)) continue;
if (!KeyExists(x.assetKey)) { missing = x.assetKey; return false; }
}
}
return true;
}
/// Addressables 키 존재(동기 · 결과 캐시). 카탈로그가 없으면 true(모르면 빼지 않는다).
public static bool KeyExists(string key)
{
if (string.IsNullOrEmpty(key)) return true;
bool has;
if (s_key.TryGetValue(key, out has)) return has;
bool anyLocator = false;
has = false;
foreach (var loc in UnityEngine.AddressableAssets.Addressables.ResourceLocators)
{
if (loc == null) continue;
anyLocator = true;
System.Collections.Generic.IList found;
// 🔴 type = null(아무 타입이나) — 타입 불일치로 **있는 카드를 잘못 빼는 것**이 더 나쁜 실패다.
if (loc.Locate(key, null, out found) && found != null && found.Count > 0)
{ has = true; break; }
}
if (!anyLocator)
{
#if UNITY_EDITOR
// 에디트 모드·카탈로그 미초기화 — 에디터에서는 AssetDatabase 로 같은 것을 본다.
has = UnityEditor.AssetDatabase.LoadAssetAtPath(key) != null;
#else
SkippedCheckCount++;
return true; // 모르면 빼지 않는다(캐시도 하지 않는다)
#endif
}
s_key[key] = has;
return has;
}
static void Remember(WLZonePickupSettings s, int id, string reason, string key)
{
LastReason = reason;
s_reason[id] = string.IsNullOrEmpty(key) ? reason : reason + ": " + key;
if (s == null || !s.logExclusion || !s_logged.Add(id)) return;
// 🔴 Debug.Log 만 쓴다(LogError/LogException = 원본 훅이 파란 모달로 만든다 · 813z 규칙).
Debug.Log("[ZonePickup] 카드 제외 — 행 " + id + " (" + s_reason[id] + ")");
}
static bool IsListed(int[] arr, int v)
{
if (arr == null) return false;
for (int i = 0; i < arr.Length; i++) if (arr[i] == v) return true;
return false;
}
static bool IsListed(string[] arr, string v)
{
if (arr == null) return false;
for (int i = 0; i < arr.Length; i++) if (string.Equals(arr[i], v)) return true;
return false;
}
}
/// 존 클리어 픽업 카드(정적 · 노드는 런타임 생성 · 프리팹 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