Project_WL/AgentScripts/WL813c2_Probe.cs

604 lines
32 KiB
C#
Raw 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.

// WL-813c2 프로브 — Q6 핫픽스 실측(에디트 모드 · Play 0 · 로그인 0 · #813).
// unity command run_script --file AgentScripts/WL813c2_Probe.cs --entry WL813c2_Probe.RunAll
// 산출물: <worktree>/AgentScripts/WL813c2_PROBE.txt
//
// 🔴 씬·프리팹을 저장하지 않는다. 만든 노드는 finally 에서 전부 DestroyImmediate(HideFlags.HideAndDontSave).
// 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복한다(AssetDatabase.SaveAssets 0).
// 🔴 Gameplay 는 읽기·구독·공개 API 호출만. `BossArena.s_armed` 만 리플렉션으로 잠깐 세운다
// (ResetGate 가 s_boss 를 비우지 않는다는 D-23 근거를 코드가 아니라 **실행**으로 남기려고 · 813q2 선례).
// 🔴 batchmode 는 Time.unscaledTime 이 전진하지 않는다 → 시각은 전부 프로브가 넘기는 t 값으로 만든다.
// 🔴 프리팹을 열지 않는다(813o 는 NewGameUI.prefab 을 열었지만 여기서는 합성 캔버스면 충분하다).
using System;
using System.IO;
using System.Reflection;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using WL.Combat.Core;
using WL.Combat.Run;
using WL.Combat.Survival;
using WL.UI;
public static class WL813c2_Probe
{
const string kSelectSkillTxt = "Assets/ResWork/Table/table_selectskill.txt";
static StringBuilder _o;
static int _pass, _fail;
static GameObject _canvas, _fakes;
static BossHpBar _bar;
static LootToast _toast;
static ReviveDialog _revive;
static Transform _uiRoot;
static void H(string s) { _o.AppendLine(); _o.AppendLine("── " + s); }
static void N(string s) { _o.AppendLine(" " + s); }
static bool Chk(bool ok, string what)
{
if (ok) { _pass++; _o.AppendLine(" [PASS] " + what); }
else { _fail++; _o.AppendLine(" [FAIL] " + what); }
return ok;
}
static string Root { get { return Directory.GetParent(Application.dataPath).FullName; } }
static string OutPath(string n) { return Path.Combine(Path.Combine(Root, "AgentScripts"), n); }
public static string RunAll()
{
_o = new StringBuilder();
_pass = _fail = 0;
_o.AppendLine("# WL813c2 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play " + Application.isPlaying + ")");
var hud = WLHudLayoutSettings.Instance;
var txt = WLCombatTextSettings.Instance;
var zp = WLZonePickupSettings.Instance;
var run = WLRunUiSettings.Instance;
// 원복용 스냅샷
bool sPoll = hud != null && hud.bossBarPollEnabled;
float sPollHz = hud != null ? hud.bossBarPollHz : 0f;
bool sArenaGate = hud != null && hud.bossBarRebindRequiresArenaSpawn;
bool sGold = txt != null && txt.lootToastIncludeGold;
float sFade = txt != null ? txt.lootToastOverflowFadeSec : 0f;
bool sMergeVis = txt != null && txt.lootToastMergeWhileVisible;
bool sPause = zp != null && zp.pauseTimeoutWhileRevive;
bool sBossSpawned = WL.Combat.Boss.BossArena.BossSpawned;
try
{
Step0_Values(hud, txt, zp, run);
BuildScene();
Step1_BarFollowsHp(hud);
Step2_RunStartReset(hud, run);
Step3_ToastQueue(txt);
Step4_CardRevivePause(zp);
Step5_Gc(hud, txt);
}
catch (Exception ex)
{
_fail++;
_o.AppendLine(" [EXCEPTION] " + ex);
}
finally
{
if (hud != null) { hud.bossBarPollEnabled = sPoll; hud.bossBarPollHz = sPollHz; hud.bossBarRebindRequiresArenaSpawn = sArenaGate; }
if (txt != null) { txt.lootToastIncludeGold = sGold; txt.lootToastOverflowFadeSec = sFade; txt.lootToastMergeWhileVisible = sMergeVis; }
if (zp != null) zp.pauseTimeoutWhileRevive = sPause;
WL.Combat.Boss.BossArena.BossSpawned = sBossSpawned;
WL.Combat.Boss.BossArena.SetBossForProbe(null);
BossHpBar.ClearFakeHp();
BossHpBar.DebugFallbackMaxHp = 100f;
try { ZonePickup.Teardown(); } catch { }
if (_toast != null) _toast.Subscribe(false);
if (_bar != null) _bar.Subscribe(false);
if (_canvas != null) UnityEngine.Object.DestroyImmediate(_canvas);
if (_fakes != null) UnityEngine.Object.DestroyImmediate(_fakes);
table_selectskill.Ins = null;
InGameInfo.Ins = null;
ActorInfo.Ins = null;
}
_o.AppendLine();
_o.AppendLine("== 합계 PASS " + _pass + " · FAIL " + _fail + " ==");
string p = OutPath("WL813c2_PROBE.txt");
File.WriteAllText(p, _o.ToString(), new UTF8Encoding(false));
return "PASS " + _pass + " · FAIL " + _fail + " → " + p;
}
// ───────────────────────────────────────────── ⓪ 값(SO)
static void Step0_Values(WLHudLayoutSettings hud, WLCombatTextSettings txt, WLZonePickupSettings zp, WLRunUiSettings run)
{
H("⓪ 값 — 이번 작업으로 늘어난 SO 필드 실측(코드 상수 0)");
Chk(hud != null, "WLHudLayoutSettings 에셋");
if (hud != null)
N("보스 바 : poll=" + hud.bossBarPollEnabled + " @" + hud.bossBarPollHz + "Hz · 아레나추종=" + hud.bossBarPollFollowsArena +
" · 사망숨김=" + hud.bossBarPollHidesOnDeath + " · 재바인드조건(BossSpawned)=" + hud.bossBarRebindRequiresArenaSpawn +
" · 눈금=" + (hud.bossPhaseTicks != null ? string.Join(",", Array.ConvertAll(hud.bossPhaseTicks, v => v.ToString("F2"))) : "-") +
" · 숨김지연=" + hud.bossBarHideDelaySec + "s");
Chk(txt != null, "WLCombatTextSettings 에셋");
if (txt != null)
N("토스트 : 줄상한=" + txt.lootToastLines + " · 표시=" + txt.lootToastSeconds + "s · 병합창=" + txt.lootToastMergeSeconds +
"s(보이는동안=" + txt.lootToastMergeWhileVisible + ") · 넘침페이드=" + txt.lootToastOverflowFadeSec +
"s · 큐=" + txt.lootToastQueueMax + " · 골드포함=" + txt.lootToastIncludeGold);
Chk(zp != null, "WLZonePickupSettings 에셋");
if (zp != null)
N("픽업카드: 부활중정지=" + zp.pauseTimeoutWhileRevive + " · 전용캔버스=" + zp.ownCanvasEnabled +
" · sortingOrder=" + zp.cardSortingOrder + " · 타임아웃=" + zp.pickTimeoutSeconds + "s · 열기지연=" + zp.openDelaySeconds + "s");
var sv = WLSurvivalSettings.Instance;
N("부활 : reviveSortingOrder=" + (sv != null ? sv.reviveSortingOrder.ToString() : "-"));
Chk(zp != null && sv != null && zp.cardSortingOrder < sv.reviveSortingOrder,
"카드 sortingOrder " + (zp != null ? zp.cardSortingOrder : -1) + " < 부활 " + (sv != null ? sv.reviveSortingOrder : -1) + " (발주서 §1-3)");
if (run != null)
N("런 UI : bossResetOnRunStart=" + run.bossResetOnRunStart + " · bossBarRebindOnRunStart=" + run.bossBarRebindOnRunStart +
" · bossBannerRetriggerOnRunStart=" + run.bossBannerRetriggerOnRunStart);
}
// ───────────────────────────────────────────── 합성 씬
static void BuildScene()
{
H("① 합성 캔버스(1080×1920 Expand) · IngameUIs/WL_HUD · 보스 바 · 토스트 · 부활 팝업");
_canvas = new GameObject("__WL813c2_Canvas", typeof(RectTransform));
_canvas.hideFlags = HideFlags.HideAndDontSave;
var c = _canvas.AddComponent<Canvas>();
c.renderMode = RenderMode.ScreenSpaceOverlay;
var sc = _canvas.AddComponent<CanvasScaler>();
sc.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
sc.referenceResolution = new Vector2(1920f, 1080f);
sc.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
_canvas.AddComponent<GraphicRaycaster>();
var ing = NewRt("IngameUIs", _canvas.transform);
var hud = NewRt("WL_HUD", ing);
hud.anchorMin = hud.anchorMax = new Vector2(0.5f, 0.5f);
hud.pivot = new Vector2(0.5f, 0.5f);
hud.sizeDelta = new Vector2(1080f, 1920f);
_uiRoot = _canvas.transform;
_bar = NewRt("WL_BossHpBar", hud).gameObject.AddComponent<BossHpBar>();
N("BossHpBar.Initialize : " + _bar.Initialize());
Chk(_bar != null, "보스 바 조립");
_toast = NewRt("WL_LootToast", hud).gameObject.AddComponent<LootToast>();
N("LootToast.Initialize : " + Trim(_toast.Initialize(), 180));
Chk(_toast != null, "토스트 조립");
_revive = NewRt("WL_ReviveDialog", hud).gameObject.AddComponent<ReviveDialog>();
N("ReviveDialog.Initialize : " + Trim(_revive.Initialize(), 180));
Chk(!ReviveDialog.Busy, "부활 팝업은 닫힌 상태로 시작");
}
static RectTransform NewRt(string name, Transform parent)
{
var go = new GameObject(name, typeof(RectTransform));
go.hideFlags = HideFlags.HideAndDontSave;
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
return rt;
}
static string Trim(string s, int n) { return s != null && s.Length > n ? s.Substring(0, n) + "…" : s; }
// ───────────────────────────────────────────── ② D-26
static void Step1_BarFollowsHp(WLHudLayoutSettings hud)
{
H("② D-26 — 보스 HP 바가 실제 HP 를 따라가는가 (Q6 `PD3_05` = 프로브 869/1800 인데 바 3칸 만피)");
if (_bar == null || hud == null) { Chk(false, "전제 없음"); return; }
// (a) 결함 재현 — MaxHP 를 못 읽은 채로 물렸다(폴백 100 고정) + 폴링 off = 813q2 상태
hud.bossBarPollEnabled = false;
BossHpBar.ClearFakeHp();
N("RaiseFakeBossSpawned(100) : " + BossHpBar.RaiseFakeBossSpawned(100f));
Chk(Math.Abs(_bar.LastRatio - 1f) < 0.001f, "물린 직후 ratio=1.000 (실측 " + _bar.LastRatio.ToString("F3") + ")");
BossHpBar.DebugFakeMaxHp = 1800f; BossHpBar.DebugFakeHp = 869f;
bool did = _bar.PollBoss(true);
Chk(!did, "폴링 off → 갱신 0(C8 롤백 = 813q2 동작)");
Chk(Math.Abs(_bar.LastRatio - 1f) < 0.001f,
"🔴 D-26 재현 — 실제 869/1800 = 0.483 인데 바는 " + _bar.LastRatio.ToString("F3") + " (MaxHP 가 폴백 " +
_bar.PolledMaxHp.ToString("F0") + " 에 굳어 Clamp01(869/100)=1.000)");
// (b) 고침 — 폴링 on
hud.bossBarPollEnabled = true;
int fixBefore = _bar.MaxHpFixCount;
did = _bar.PollBoss(true);
Chk(did, "폴링 on → 갱신 1회");
Chk(_bar.MaxHpFixCount == fixBefore + 1, "MaxHP 보정 1회(100 → " + _bar.PolledMaxHp.ToString("F0") + ")");
Chk(Math.Abs(_bar.LastRatio - 0.483f) < 0.001f,
"발주서 §1-5 — 1800→869 → fill " + _bar.LastRatio.ToString("F3") + " (기대 0.483)");
Chk(Math.Abs(FillAmount() - _bar.LastRatio) < 0.0001f, "Image.fillAmount 실측 = " + FillAmount().ToString("F3"));
// (c) 계속 따라가는가 — 이벤트 없이 값만 바뀌어도(리쉬 회복·리젠 경로) 따라온다
BossHpBar.DebugFakeHp = 1251f; // 결과창 시점 69.5 %
_bar.PollBoss(true);
Chk(Math.Abs(_bar.LastRatio - 0.695f) < 0.002f, "HP 가 올라가도 따라간다(회복 경로) ratio=" + _bar.LastRatio.ToString("F3"));
BossHpBar.DebugFakeHp = 180f;
_bar.PollBoss(true);
Chk(Math.Abs(_bar.LastRatio - 0.1f) < 0.002f, "10 % 구간 ratio=" + _bar.LastRatio.ToString("F3"));
// (d) 3칸 분할 표시 유지
int ticks = TickCount();
Chk(ticks == 2, "페이즈 눈금 " + ticks + "개 = 3칸 분할 유지(0.70 / 0.40)");
N("눈금 위치 : " + TickPositions());
// (e) 사망 → 0 → 숨김
BossHpBar.DebugFakeHp = 0f;
_bar.PollBoss(true);
Chk(Math.Abs(_bar.LastRatio) < 0.0001f, "HP 0 → fill 0.000");
N("RaiseFakeKilled : " + BossHpBar.RaiseFakeKilled());
Chk(Math.Abs(_bar.LastRatio) < 0.0001f && _bar.KilledSeen > 0, "처치 이벤트 수신 → 0 유지(숨김은 bossBarHideDelaySec 뒤)");
// (f) 물지 않은 상태에서는 폴링이 아무 것도 켜지 않는다(D-23 과의 상호작용)
BossHpBar.ClearFakeHp();
_bar.ResetForNewRun();
WL.Combat.Boss.BossArena.BossSpawned = false;
bool before = _bar.Visible;
_bar.PollBoss(true);
Chk(!_bar.Visible && !before, "물림 해제 상태 + BossSpawned=false → 폴링이 바를 다시 켜지 않는다");
N(_bar.Dump());
}
static float FillAmount()
{
var f = _bar.GetComponentInChildren<Image>(true);
var all = _bar.GetComponentsInChildren<Image>(true);
for (int i = 0; i < all.Length; i++) if (all[i].name == "Fill") return all[i].fillAmount;
return f != null ? f.fillAmount : -1f;
}
static Transform Ticks()
{
var t = _bar.transform.Find("Bar/Ticks");
return t;
}
static int TickCount() { var t = Ticks(); return t != null ? t.childCount : -1; }
static string TickPositions()
{
var t = Ticks();
if (t == null) return "(없음)";
var sb = new StringBuilder();
for (int i = 0; i < t.childCount; i++)
{
var rt = t.GetChild(i) as RectTransform;
if (rt != null) sb.Append(rt.name).Append("@").Append(rt.anchorMin.x.ToString("F2")).Append(" ");
}
return sb.ToString();
}
// ───────────────────────────────────────────── ③ D-23
static void Step2_RunStartReset(WLHudLayoutSettings hud, WLRunUiSettings run)
{
H("③ D-23 잔여 — 「다음 런」 뒤 이전 런 보스 바 잔존 (Q6 `PD3_07`)");
if (_bar == null || hud == null) { Chk(false, "전제 없음"); return; }
// (a) 왜 813q2 리셋이 안 먹은 것처럼 보였나 — ResetGate 가 BossArena.s_boss 를 비우지 않는다(실행으로 확인)
var armed = typeof(WL.Combat.Boss.BossArena).GetField("s_armed", BindingFlags.Static | BindingFlags.NonPublic);
Chk(armed != null, "BossArena.s_armed 필드 확보(리플렉션 · 읽기/쓰기 1회)");
var probeGo = new GameObject("__WL813c2_FakeBoss");
probeGo.SetActive(false); // 비활성 = Awake 미실행 · IsAliveBoss 도 false
probeGo.hideFlags = HideFlags.HideAndDontSave;
MobActor fakeBoss = null;
try { fakeBoss = probeGo.AddComponent<MobActor>(); } catch (Exception ex) { N("MobActor 부착 실패 — " + ex.Message); }
if (fakeBoss != null && armed != null)
{
bool armedBefore = (bool)armed.GetValue(null);
armed.SetValue(null, true);
WL.Combat.Boss.BossArena.SetBossForProbe(fakeBoss);
WL.Combat.Boss.BossArena.BossSpawned = true;
Chk(WL.Combat.Boss.BossArena.Boss != null, "아레나가 보스를 잡고 있다");
WL.Combat.Boss.BossArena.ResetGate();
Chk(WL.Combat.Boss.BossArena.Boss != null,
"🔴 원인 — `BossArena.ResetGate()`(다음 런) 뒤에도 `BossArena.Boss` 가 **그대로 남는다** → 813q2 리셋 직후 " +
"`BindExistingBoss()` 가 이전 런 보스를 즉시 다시 문다(= 화면상 「잔존」)");
Chk(!WL.Combat.Boss.BossArena.BossSpawned, "반면 `BossSpawned` 는 ResetGate 가 false 로 되돌린다 → 이 값을 재바인드 조건으로 쓴다");
WL.Combat.Boss.BossArena.SetBossForProbe(null);
armed.SetValue(null, armedBefore);
}
UnityEngine.Object.DestroyImmediate(probeGo);
// (b) 고침 — RunStarted 로 숨김 + 이번 런 보스 미등장이면 재바인드 안 함
hud.bossBarPollEnabled = true;
hud.bossBarRebindRequiresArenaSpawn = true;
BossHpBar.DebugFakeMaxHp = 1800f; BossHpBar.DebugFakeHp = 869f;
N("RaiseFakeBossSpawned(1800) : " + BossHpBar.RaiseFakeBossSpawned(1800f));
_bar.PollBoss(true);
Chk(_bar.Visible, "런1 — 바 표시 중(ratio " + _bar.LastRatio.ToString("F3") + ")");
WL.Combat.Boss.BossArena.BossSpawned = false; // 「다음 런」 = ResetGate 직후 상태
int resetBefore = BossHpBar.RunResetCount;
int subs = RunEvents.RunStarted.Count;
N("RunStarted 구독자 = " + subs);
WLRunUiProbeHooks.RaiseRunStarted(2, 270f, 2, "probe-813c2");
Chk(BossHpBar.RunResetCount == resetBefore + 1, "RunStarted → 런 시작 리셋 1회");
Chk(!_bar.Visible, "✅ D-23 — 「다음 런」 직후 보스 바 **숨김**(visible=" + _bar.Visible + ")");
Chk(BossHpBar.RunBossHpRatio < 0f, "결과 화면이 읽는 런 단위 HP 값도 비워졌다(" + BossHpBar.RunBossHpRatio.ToString("F2") + ")");
_bar.PollBoss(true);
Chk(!_bar.Visible, "그 뒤 폴링을 돌려도 다시 켜지지 않는다(재바인드 조건 미충족)");
// (c) C8 롤백 — 조건을 끄면 예전 경로(즉시 재바인드 시도)로 돌아간다
hud.bossBarRebindRequiresArenaSpawn = false;
string r = _bar.ResetForNewRun();
N("조건 off 리셋 결과 : " + r);
Chk(r.Contains("늦은구독"), "C8 롤백 — 조건을 끄면 곧바로 `BindExistingBoss()` 로 간다(813q2 동작)");
hud.bossBarRebindRequiresArenaSpawn = true;
// (d) 스위치 자체 롤백
if (run != null)
{
bool sv = run.bossResetOnRunStart;
run.bossResetOnRunStart = false;
string r2 = _bar.ResetForNewRun();
Chk(r2.Contains("bossResetOnRunStart=false"), "C8 — bossResetOnRunStart=0 → 리셋 자체 생략(813q 동작)");
run.bossResetOnRunStart = sv;
}
BossHpBar.ClearFakeHp();
}
// ───────────────────────────────────────────── ④ D-31
static void Step3_ToastQueue(WLCombatTextSettings txt)
{
H("④ D-31 — 획득 토스트 (Q6: 60 s 21건 · 「창 안 6줄」)");
if (_toast == null || txt == null) { Chk(false, "전제 없음"); return; }
N("🔴 실측 구분 — Q6 의 「창 안 6줄」은 프로브가 **표시 시간 창 안의 Picked 이벤트 수**를 센 값이다");
N(" (`Assets/WL/Tools/WL813_ScoreProbe.cs:437~443`). 화면 동시 줄 수는 813g 때부터 lootToastLines 로 막혀 있다.");
_toast.HideAll();
_toast.ResetPollBaseline();
// (a) 서로 다른 아이템 21건 연속 — 동시 줄 수가 상한을 넘지 않는다
int maxSeen = 0;
for (int i = 0; i < 21; i++)
{
_toast.Push(9000 + i, 1);
if (_toast.VisibleLines > maxSeen) maxSeen = _toast.VisibleLines;
}
Chk(maxSeen <= txt.lootToastLines, "서로 다른 21건 → 화면 동시 최대 " + maxSeen + "줄 ≤ 상한 " + txt.lootToastLines);
Chk(_toast.OverflowFadeCount > 0, "넘친 만큼 오래된 줄을 즉시 페이드시켰다(" + _toast.OverflowFadeCount + "회 · " +
txt.lootToastOverflowFadeSec.ToString("F2") + "s)");
Chk(_toast.QueuedCount > 0 || _toast.QueueDropCount > 0, "나머지는 큐에서 자리를 기다린다(대기 " + _toast.QueuedCount +
" · 큐버림 " + _toast.QueueDropCount + ")");
N(_toast.Dump());
// 자리가 나면 큐가 순서대로 나간다(런타임은 Update 가 매 프레임 부른다)
int queued = _toast.QueuedCount;
_toast.HideAll(); // 줄 3개가 비었다고 가정
for (int i = 0; i < 21; i++) _toast.Push(9100 + i, 1);
int held = _toast.QueuedCount;
ForceExpireLines(); // 배치 모드는 Update 가 안 돌아 수명을 직접 만료시킨다
int flushed = _toast.FlushQueueNow();
Chk(flushed > 0, "자리가 나자 큐에서 " + flushed + "건이 나갔다(대기 " + held + " → " + _toast.QueuedCount + ")");
Chk(_toast.VisibleLines <= txt.lootToastLines, "큐가 나가도 동시 줄 수는 상한 유지(실측 " + _toast.VisibleLines + ")");
N("첫 배치 대기 " + queued + " · 두 번째 배치 대기 " + held);
// (b) 같은 아이템 연속 획득 = 한 줄 수량 병합
_toast.HideAll();
bool svGold = txt.lootToastIncludeGold;
txt.lootToastIncludeGold = true;
try
{
for (int i = 0; i < 8; i++) _toast.Push(LootToast.GoldItemId, 1);
Chk(_toast.VisibleLines == 1, "같은 아이템 8건 → 줄 " + _toast.VisibleLines + "개(병합)");
string line = FirstLineText();
Chk(line.Contains("x8"), "병합 문구 = \"" + line + "\" (형식 " + txt.lootToastFormat + " · 표 미로드라 이름은 ID)");
}
finally { txt.lootToastIncludeGold = svGold; }
// (c) 병합 창 밖이어도 보이는 동안이면 병합한다(줄 폭주 방지)
_toast.HideAll();
_toast.Push(9500, 3);
AdvanceLines(txt.lootToastMergeSeconds + 0.2f); // 병합 창은 지났지만 아직 보이는 상태
_toast.Push(9500, 2);
Chk(_toast.VisibleLines == 1, "병합 창(" + txt.lootToastMergeSeconds + "s) 밖이어도 보이는 동안이면 한 줄 — 실측 " + _toast.VisibleLines);
bool svVis = txt.lootToastMergeWhileVisible;
txt.lootToastMergeWhileVisible = false;
try
{
_toast.HideAll();
_toast.Push(9600, 1);
AdvanceLines(txt.lootToastMergeSeconds + 0.2f);
_toast.Push(9600, 1);
Chk(_toast.VisibleLines == 2, "C8 — 스위치를 끄면 창 밖은 새 줄(813g 동작) 실측 " + _toast.VisibleLines);
}
finally { txt.lootToastMergeWhileVisible = svVis; }
// (d) C8 — 넘침 페이드 0 이면 813g 의 즉시 한 칸 당김
_toast.HideAll();
float svFade = txt.lootToastOverflowFadeSec;
txt.lootToastOverflowFadeSec = 0f;
try
{
int q0 = _toast.QueuedCount;
for (int i = 0; i < 6; i++) _toast.Push(9700 + i, 1);
Chk(_toast.QueuedCount == q0, "C8 — 페이드 0 이면 큐를 쓰지 않는다(대기 " + _toast.QueuedCount + ")");
Chk(_toast.VisibleLines <= txt.lootToastLines, "그래도 동시 줄 수는 상한 " + txt.lootToastLines + " 유지(실측 " + _toast.VisibleLines + ")");
}
finally { txt.lootToastOverflowFadeSec = svFade; }
// (e) 전체 off
_toast.HideAll();
bool svOn = txt.lootToastEnabled;
txt.lootToastEnabled = false;
try
{
_toast.Push(9800, 1);
Chk(_toast.VisibleLines == 0, "C8 — lootToastEnabled=0 → 표시 0");
}
finally { txt.lootToastEnabled = svOn; }
_toast.HideAll();
}
static string FirstLineText()
{
var all = _toast.GetComponentsInChildren<TextMeshProUGUI>(true);
for (int i = 0; i < all.Length; i++)
if (!string.IsNullOrEmpty(all[i].text)) return all[i].text;
return "";
}
/// <summary>배치 모드는 시간이 안 흐른다 — 줄 수명(elapsed)만 리플렉션으로 밀어 「창 밖」을 만든다.</summary>
static void AdvanceLines(float sec)
{
foreach (var l in LinesOf())
{
var fe = l.GetType().GetField("elapsed");
if (fe != null) fe.SetValue(l, (float)fe.GetValue(l) + sec);
}
}
/// <summary>배치 모드에서 줄 수명이 다 된 상태를 만든다(런타임은 Update 가 한다).</summary>
static void ForceExpireLines()
{
foreach (var l in LinesOf())
{
var fu = l.GetType().GetField("used");
var fl = l.GetType().GetField("life");
if (fu != null) fu.SetValue(l, false);
if (fl != null) fl.SetValue(l, 0f);
}
}
static System.Collections.IEnumerable LinesOf()
{
var f = typeof(LootToast).GetField("_lines", BindingFlags.Instance | BindingFlags.NonPublic);
var list = f != null ? f.GetValue(_toast) as System.Collections.IEnumerable : null;
return list ?? new object[0];
}
// ───────────────────────────────────────────── ⑤ D-29
static void Step4_CardRevivePause(WLZonePickupSettings zp)
{
H("⑤ D-29 — 부활 팝업이 카드를 덮는 동안 카드 타이머 정지 (Q6 `R3_15`)");
if (zp == null || _uiRoot == null) { Chk(false, "전제 없음"); return; }
// 원본 데이터 경로 대역(813o 선례) — 카드 뽑기는 원본 table_selectskill / InGameInfo 를 그대로 쓴다
_fakes = new GameObject("__WL813c2_Infos");
_fakes.hideFlags = HideFlags.HideAndDontSave;
var tbl = _fakes.AddComponent<table_selectskill>();
tbl.m_json = AssetDatabase.LoadAssetAtPath<TextAsset>(kSelectSkillTxt);
var awake = typeof(table_selectskill).GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance);
if (awake != null) awake.Invoke(tbl, null);
Chk(table_selectskill.Ins != null, "table_selectskill.Ins(원본 Awake 경로)");
InGameInfo.Ins = _fakes.AddComponent<InGameInfo>();
ActorInfo.Ins = _fakes.AddComponent<ActorInfo>();
ZonePickup.Subscribe(true);
N("ZonePickup.Bind : " + ZonePickup.Bind(_uiRoot));
Chk(ZonePickup.Bound, "카드 노드 조립");
// (a) 정렬 — 카드가 부활 팝업 아래
var sv = WLSurvivalSettings.Instance;
Chk(ZonePickup.SortingOrder == zp.cardSortingOrder, "카드 전용 Canvas sortingOrder = " + ZonePickup.SortingOrder);
Chk(sv != null && ZonePickup.SortingOrder < sv.reviveSortingOrder,
"✅ 카드(" + ZonePickup.SortingOrder + ") < 부활 팝업(" + (sv != null ? sv.reviveSortingOrder : -1) + ") = 부활이 위로 온다");
// (b) 카드를 먼저 연다
ZonePickup.ResetState();
RunDirector.EnsureSubscribed();
RunDirector.StartRun("probe-813c2");
WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 30f, 12, 12, false);
float t0 = ZonePickup.NowClock();
ZonePickup.Tick(t0 + zp.openDelaySeconds + 0.01f);
if (!Chk(ZonePickup.IsOpen, "카드 열림 — " + ZonePickup.Dump())) return;
float tOpen = t0 + zp.openDelaySeconds + 0.01f;
// (c) 3초 흐른 뒤 사망 → 부활 팝업이 카드 위로 온다
ZonePickup.Tick(tOpen + 3f);
int secBefore = ZonePickup.ShownSecond;
N("3 s 경과 — 남은 초 표시 = " + ZonePickup.TimerText + " (" + secBefore + ")");
N("ReviveDialog.ShowNowOnce : " + Trim(_revive.ShowNowOnce(), 160));
Chk(ReviveDialog.Busy, "부활 팝업 Busy = " + ReviveDialog.Busy);
Chk(ZonePickup.IsOpen, "카드는 닫히지 않는다(부활 아래에 그대로 있다)");
// (d) 부활 팝업이 떠 있는 동안 9초를 흘려도 자동 선택되지 않는다 = D-29 고침
for (int i = 1; i <= 9; i++) ZonePickup.Tick(tOpen + 3f + i);
Chk(ZonePickup.TimeoutHeldByRevive, "타이머 정지 상태 = " + ZonePickup.TimeoutHeldByRevive);
Chk(ZonePickup.IsOpen && ZonePickup.TimeoutCount == 0,
"✅ D-29 — 부활 중 9 s 가 흘러도 자동 선택 0(열림=" + ZonePickup.IsOpen + " timeout=" + ZonePickup.TimeoutCount + ")");
Chk(ZonePickup.ShownSecond == secBefore,
"남은 초 표시도 멈춰 있다 " + secBefore + " → " + ZonePickup.ShownSecond + " (\"" + ZonePickup.TimerText + "\")");
Chk(Math.Abs(ZonePickup.PausedSeconds - 9f) < 0.05f, "누적 정지 " + ZonePickup.PausedSeconds.ToString("F2") + "s (기대 9.00)");
Chk(ZonePickup.ReviveHoldCount == 1, "정지 진입 1회 — 실측 " + ZonePickup.ReviveHoldCount);
// (e) 부활이 닫히면 남은 초부터 재개
_revive.HideNow();
Chk(!ReviveDialog.Busy, "부활 팝업 닫힘");
ZonePickup.Tick(tOpen + 12f);
Chk(!ZonePickup.TimeoutHeldByRevive && ZonePickup.ReviveResumeCount == 1, "재개 1회 — 실측 " + ZonePickup.ReviveResumeCount);
Chk(ZonePickup.IsOpen, "재개 직후에도 아직 열려 있다(남은 " + (zp.pickTimeoutSeconds - 3f).ToString("F0") + "초부터)");
ZonePickup.Tick(tOpen + 3f + 9f + zp.pickTimeoutSeconds - 3f + 0.1f);
Chk(!ZonePickup.IsOpen && ZonePickup.TimeoutCount == 1,
"정지분(9 s)을 뺀 남은 시간이 다 지나면 그때 자동 선택(timeout=" + ZonePickup.TimeoutCount + ")");
// (f) C8 — 스위치를 끄면 D-29 가 그대로 재현된다
bool svPause = zp.pauseTimeoutWhileRevive;
zp.pauseTimeoutWhileRevive = false;
try
{
ZonePickup.ResetState();
WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 30f, 12, 12, false);
float u0 = ZonePickup.NowClock();
ZonePickup.Tick(u0 + zp.openDelaySeconds + 0.01f);
float uOpen = u0 + zp.openDelaySeconds + 0.01f;
Chk(ZonePickup.IsOpen, "C8 — 카드 다시 열림");
N("ReviveDialog.ShowNowOnce : " + Trim(_revive.ShowNowOnce(), 120));
for (int i = 1; i <= 11; i++) ZonePickup.Tick(uOpen + i);
Chk(ZonePickup.TimeoutCount == 1 && !ZonePickup.IsOpen,
"🔴 C8 재현 — 스위치를 끄면 부활 팝업 아래에서 타이머가 흘러 자동 선택된다(= Q6 D-29 그대로)");
_revive.HideNow();
}
finally { zp.pauseTimeoutWhileRevive = svPause; ZonePickup.ResetState(); }
// (g) 전용 캔버스 롤백
bool svCanvas = zp.ownCanvasEnabled;
zp.ownCanvasEnabled = false;
try
{
ZonePickup.ApplyLayout();
Chk(ZonePickup.SortingOrder == -1, "C8 — ownCanvasEnabled=0 → overrideSorting 해제(형제 순서만 · 813o 동작)");
}
finally { zp.ownCanvasEnabled = svCanvas; ZonePickup.ApplyLayout(); }
Chk(ZonePickup.SortingOrder == zp.cardSortingOrder, "복구 sortingOrder=" + ZonePickup.SortingOrder);
}
// ───────────────────────────────────────────── ⑥ GC
static void Step5_Gc(WLHudLayoutSettings hud, WLCombatTextSettings txt)
{
H("⑥ GC — 매 프레임 경로(보스 바 폴링 · 카드 틱 · 토스트 수명)에서 할당 0");
if (_bar == null || hud == null) { Chk(false, "전제 없음"); return; }
hud.bossBarPollEnabled = true;
BossHpBar.DebugFakeMaxHp = 1800f; BossHpBar.DebugFakeHp = 869f;
BossHpBar.RaiseFakeBossSpawned(1800f);
_bar.PollBoss(true);
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long a = GC.GetTotalMemory(true);
for (int i = 0; i < 20000; i++) _bar.PollBoss(true);
long b = GC.GetTotalMemory(false);
N("보스 바 폴링 20,000회 : " + a + " → " + b + " (" + (b - a) + " B)");
Chk(b - a < 4096, "폴링 경로 할당 ≈ 0 (실측 " + (b - a) + " B / 20,000회)");
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
a = GC.GetTotalMemory(true);
float t = ZonePickup.NowClock();
for (int i = 0; i < 20000; i++) ZonePickup.Tick(t);
b = GC.GetTotalMemory(false);
N("픽업 Tick(대기 상태) 20,000회 : " + (b - a) + " B");
Chk(b - a < 4096, "카드 틱 경로 할당 ≈ 0 (실측 " + (b - a) + " B / 20,000회)");
BossHpBar.ClearFakeHp();
}
}