Project_WL/Assets/WL/UI/Scripts/ZonePickup.cs

1117 lines
56 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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,
}
// ─────────────────────────────────────────────────────────────────────────
// 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.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>선택스킬 행이 참조하는 프리팹 키가 실제로 있는지 본다(결과 캐시).</summary>
public static class ZonePickupPool
{
/// <summary>검사한 행 수(누적 · 프로브가 읽는다).</summary>
public static int CheckedCount;
/// <summary>부재 키 때문에 뺀 행 수(누적).</summary>
public static int MissingKeyCount;
/// <summary>SO `excludeSkillIds` 로 뺀 행 수(누적).</summary>
public static int StaticExcludeCount;
/// <summary>카탈로그가 없어 키 검사를 건너뛴 횟수.</summary>
public static int SkippedCheckCount;
/// <summary>마지막 제외 사유(진단용).</summary>
public static string LastReason = "";
/// <summary>마지막으로 부재로 판정된 키.</summary>
public static string LastMissingKey = "";
/// <summary>ID → 사용 가능(true) / 제외(false). 판정 1회 뒤 재사용.</summary>
static readonly Dictionary<int, bool> s_ok = new Dictionary<int, bool>(64);
/// <summary>제외된 ID → 사유(로그·프로브용).</summary>
static readonly Dictionary<int, string> s_reason = new Dictionary<int, string>(8);
/// <summary>키 → 존재. 같은 키를 여러 행이 써도 1회만 본다.</summary>
static readonly Dictionary<string, bool> s_key = new Dictionary<string, bool>(64);
/// <summary>이미 로그를 남긴 ID(같은 줄을 반복하지 않는다).</summary>
static readonly HashSet<int> s_logged = new HashSet<int>();
/// <summary>프로브·런 경계용 — 캐시와 카운터를 비운다.</summary>
public static void ResetCache()
{
s_ok.Clear(); s_reason.Clear(); s_key.Clear(); s_logged.Clear();
CheckedCount = MissingKeyCount = StaticExcludeCount = SkippedCheckCount = 0;
LastReason = ""; LastMissingKey = "";
}
/// <summary>이 ID 가 제외라면 사유, 아니면 "" (프로브가 읽는다).</summary>
public static string ReasonFor(int id)
{
string r;
return s_reason.TryGetValue(id, out r) ? r : "";
}
/// <summary>검증 축이 살아 있는가(끄면 813o 동작 그대로).</summary>
public static bool Active(WLZonePickupSettings s) { return s != null && s.validatePool; }
/// <summary>이 행을 뽑기에서 빼야 하는가. `reason` 은 로그·프로브용(뺄 때만 채운다).</summary>
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;
}
/// <summary>이 행이 요구하는 키가 전부 있는가. 없으면 `missing` 에 첫 부재 키.</summary>
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;
}
/// <summary>Addressables 키 존재(동기 · 결과 캐시). 카탈로그가 없으면 true(모르면 빼지 않는다).</summary>
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<UnityEngine.ResourceManagement.ResourceLocations.IResourceLocation> 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<GameObject>(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;
}
}
/// <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 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; // 지금 부활 때문에 멈춰 있는가
/// <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;
/// <summary>WL-813c2(D-29) — 열린 카드의 타이머를 부활 때문에 멈춘/재개한 횟수와 누적 정지 시간(진단).</summary>
public static int ReviveHoldCount, ReviveResumeCount, ReviveHoldTicks;
/// <summary>WL-813o2(D-41) — 뽑기 결과에서 뺀 카드 수와 다시 뽑은 횟수(진단).</summary>
public static int ExcludedCount, RedrawCount;
public static string LastExcludeReason = "";
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; } }
/// <summary>WL-813c2(D-29) — 지금 부활 팝업 때문에 카드 타이머가 멈춰 있는가.</summary>
public static bool TimeoutHeldByRevive { get { return s_reviveHeld; } }
/// <summary>WL-813c2(D-29) — 이번 카드에서 부활 때문에 멈춘 누적 시간(초).</summary>
public static float PausedSeconds { get { return s_pausedFor; } }
/// <summary>WL-813c2(D-29) — 카드 전용 Canvas 의 sortingOrder(없으면 1). 부활 팝업(60)보다 작아야 한다.</summary>
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;
}
/// <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 규칙).
/// WL-813o2: 그 **결과만 후필터**해서 참조 프리팹이 없는 행을 뺀다(원본 수정 0).
/// 모자라면 원본 뽑기를 다시 부른다(최대 `maxRedraws` · 매번 새 셔플이라 분포는 그대로).
/// `validatePool = 0` 이면 시도 1회 · 제외 0 = 813o 동작과 완전히 같다.
/// </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);
bool filter = ZonePickupPool.Active(s);
int tries = filter ? 1 + Mathf.Max(0, s.maxRedraws) : 1;
try
{
for (int a = 0; a < tries && s_ids.Count < want; a++)
{
if (a > 0) RedrawCount++;
var list = t.Get_DataList(want);
if (list == null) break;
for (int i = 0; i < list.Count && s_ids.Count < want; i++)
{
var row = list[i] as SelectSkillTableData;
if (row == null) continue;
if (s_ids.Contains(row.ID)) continue; // 재뽑기 중복 방지
string why;
if (filter && ZonePickupPool.Excluded(s, row, out why))
{
ExcludedCount++;
LastExcludeReason = "행 " + row.ID + " — " + why;
continue;
}
s_ids.Add(row.ID);
s_rows.Add(row);
}
}
if (s_ids.Count < want) { s_ids.Clear(); s_rows.Clear(); return false; }
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;
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;
}
/// <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;
ApplySorting(s);
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");
}
/// <summary>
/// WL-813c2 (Q6 D-29) — 카드가 **부활 팝업 아래**로 오게 그리는 순서를 값으로 고정한다.
/// 813z 부활 팝업은 전용 Canvas `overrideSorting · sortingOrder = 60`(`WLSurvivalSettings.reviveSortingOrder` 실측)
/// 이므로 카드는 그보다 작은 값(기본 40)을 쓴다. 전용 Canvas 는 자기 `GraphicRaycaster` 가 있어야 눌린다.
/// </summary>
private static void ApplySorting(WLZonePickupSettings s)
{
if (s_root == null) return;
if (s == null || !s.ownCanvasEnabled)
{
// C8 롤백 = 전용 캔버스를 렌더 경로에서 뺀다(형제 순서만 = 813o 동작).
if (s_canvas != null) { s_canvas.overrideSorting = false; if (s_canvas.enabled) s_canvas.enabled = false; }
if (s_raycaster != null && s_raycaster.enabled) s_raycaster.enabled = false;
return;
}
if (s_canvas == null)
{
s_canvas = s_root.GetComponent<Canvas>();
if (s_canvas == null) s_canvas = s_root.gameObject.AddComponent<Canvas>();
}
if (!s_canvas.enabled) s_canvas.enabled = true;
if (!s_canvas.overrideSorting) s_canvas.overrideSorting = true;
if (s_canvas.sortingOrder != s.cardSortingOrder) s_canvas.sortingOrder = s.cardSortingOrder;
if (s_raycaster == null)
{
s_raycaster = s_root.GetComponent<GraphicRaycaster>();
if (s_raycaster == null) s_raycaster = s_root.gameObject.AddComponent<GraphicRaycaster>();
}
if (!s_raycaster.enabled) s_raycaster.enabled = true;
}
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;
ReviveHoldCount = ReviveResumeCount = ReviveHoldTicks = 0;
ExcludedCount = RedrawCount = 0;
s_picksThisRun = 0;
LastLog = ""; LastSkipReason = ""; LastExcludeReason = "";
return "리셋";
}
/// <summary>
/// WL-813o2 프로브 전용 — **뽑기만** 1회 돌린다(노드·표시·정지 0).
/// 결과는 `CardCount` / `CardId(i)` 로 읽는다. 게임 코드는 부르지 않는다.
/// </summary>
public static bool DrawForProbe()
{
var s = St;
return s != null && DrawCards(s);
}
public static string Teardown()
{
Subscribe(false);
Close("teardown");
if (s_root != null) Object.DestroyImmediate(s_root.gameObject);
s_root = null; s_group = null; s_canvas = null; s_raycaster = 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 +
" · [813c2] 부활정지=" + s_reviveHeld + "(정지 " + ReviveHoldCount + "회 · 재개 " + ReviveResumeCount +
"회 · 틱 " + ReviveHoldTicks + " · 누적 " + s_pausedFor.ToString("F2") + "s)" +
" sortingOrder=" + SortingOrder + "(부활 " +
(WL.Combat.Survival.WLSurvivalSettings.Instance != null
? WL.Combat.Survival.WLSurvivalSettings.Instance.reviveSortingOrder.ToString() : "-") +
") raycaster=" + (s_raycaster != null);
}
/// <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());
}
}
}