1117 lines
56 KiB
C#
1117 lines
56 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<T>.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());
|
||
}
|
||
}
|
||
}
|