From 60b74c122109b3ef7f80bf6515f502e338aa4b2b Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 13:27:13 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813q2]=20=EA=B2=B0=EA=B3=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=95=AB=ED=94=BD=EC=8A=A4:=20=EB=B6=80=ED=99=9C?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=A8=20=C2=B7=20=EB=8B=A4=EC=9D=8C=20=EB=9F=B0?= =?UTF-8?q?=20=EB=A6=AC=EC=85=8B=20=C2=B7=20=ED=96=89=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813q2_PROBE.txt | 177 +++++ AgentScripts/WL813q2_Probe.cs | 700 ++++++++++++++++++ Assets/WL/UI/Scripts/BossBanner.cs | 24 + Assets/WL/UI/Scripts/BossHpBar.cs | 40 + Assets/WL/UI/Scripts/RunHud.cs | 42 +- Assets/WL/UI/Scripts/RunResultUI.cs | 250 ++++++- .../Resources/WL/WLRunUiSettings.asset | 17 + Assets/WL/UI/Settings/WLRunUiSettings.cs | 64 ++ 8 files changed, 1301 insertions(+), 13 deletions(-) create mode 100644 AgentScripts/WL813q2_PROBE.txt create mode 100644 AgentScripts/WL813q2_Probe.cs diff --git a/AgentScripts/WL813q2_PROBE.txt b/AgentScripts/WL813q2_PROBE.txt new file mode 100644 index 000000000..816f02535 --- /dev/null +++ b/AgentScripts/WL813q2_PROBE.txt @@ -0,0 +1,177 @@ +=== WL-813q2 ① D-18 결과 화면 위 부활 팝업 겹침 (에디트 모드 · Play 0) === +근거: Q5 캡처 PD2_07 — 제목 "시간 종료"(= 결과가 **먼저** 떴다) 위에 부활 팝업이 겹쳐 4행이 가려졌다. +실측 전제: 813z 부활 팝업 sortingOrder = 60 · 결과 화면 SO sortingOrder = 40(부활 중 20) + +Bind: 연결 IngameUIs/WL_HUD · box size(1528.89, 1920.00) 행틀=10 unitsPerPx=1.7778 · sortingOrder=40 + PASS 결과 화면 노드 조립됨 + PASS 전용 Canvas sortingOrder = 40 (SO 40) < 부활 60 + +A. 캡처 순서 재현 — RunEnded → 결과 표시 → **그 뒤** 사망(런 밖) + PASS RunEnded 로 결과 화면이 떴다(open=True 제목 "시간 종료") + PASS 런 밖 상태(IsRunning=False) + 사망 직후: DeathFlow=Dying · 즉시부활 호출 1회 · Died — 런 밖 사망 → 팝업 생략 즉시 부활(True) + PASS 런 밖 사망 → 팝업 생략 즉시 부활을 불렀다(skipRevivePopupAfterRunEnd=1) + 연출 끝: DeathFlow=None · ReviveRequested 1회(팝업이 1프레임 안에 닫힌다) · Revived=1 + PASS 부활 완료 — DeathFlow.State=None(부활 대기 0 = 팝업이 남지 않는다) + PASS DeathFlow.ReviveCount=1 + PASS 결과 화면은 그대로 · 재정렬 없음(open=True lowered=False) + +B. skipRevivePopupAfterRunEnd=0 (C8 롤백) — 팝업이 뜨는 경우 결과를 부활 **뒤로 재정렬** + PASS 결과 화면 재표시(open=True) + 사망 후: Died — 런 중 사망(재정렬=True) + PASS 즉시 부활 호출 0(스위치 off = 813q 동작) + PASS 결과를 부활 뒤로 재정렬 sortingOrder 40 → 20 (부활 60 아래) + PASS 형제 순서 2 → 0 (SetAsFirstSibling) + PASS 부활 대기 상태(DeathFlow=AwaitingRevive) + PASS 「부활 우선」 판정 ON — 이유 = DeathFlow.AwaitingRevive + PASS 부활 완료 → 결과 화면 복귀 sortingOrder=40 형제=2 + +C. 왜 813q 판정이 못 잡았나 — RunEnded 시점에는 아무도 죽지 않아 Busy=false 였다 + PASS RunEnded 직전 실측: ReviveDialog.Busy=False · DeathFlow=None → 813q 의 미룸 조건은 어느 쪽으로도 걸리지 않는다 + +D. 반대 순서 — 사망(런 중) → RunEnded → 부활 뒤에 결과가 뜬다 + PASS 사망 연출 중(DeathFlow=Dying) + PASS 「부활 우선」 ON — DeathFlow.Dying + PASS 결과 화면 미룸(open=False pending=True defer=1) + PASS 부활 뒤 결과 화면 표시(open=True shows=1) + +[RunResultUI] 구독=True bound=True open=True pending=False · ended=1 shows=1 defer=1 next=0 close=0 abandon생략=0 버퍼증설=0 공용팝업=False + 제목 "시간 종료" · 행 10개 · 부활팝업Busy=False + [813q2] 부활우선=False(없음) · DeathFlow=None 런=Ended(running=False) · died=1 request=1 revived=1 팝업생략부활=1 · 재정렬=False(내림 0/복귀 0) sortingOrder=40 sibling=2 + [813q2] 엘리트 런중=0 종료스냅=0 전체본것=0 · 보스HP(UI 실측)=없음 · 마지막 사망 로그 = ReviveRequested — 런 밖 사망 → 팝업 생략 즉시 부활(True) + - TotalTime : "04:01" + - BossClear : "미처치" + - Kills : "29 (보스 0)" + - EliteKills : "0" + - MaxChain : "7" + - MaxDamage : "8889" + - Level : "11 → 12" + - LootByGrade : "5 2" + - Gold : "777" + - DeathsPotions : "1 / 2" + 복사본 outcome=Timeout runIndex=1 total=241.0s bossClear=-1.0s kills=29(보스 0) chain=7 dmg=1234.4/8888.5 lv=11→12 lootTotal=7 gold=777 deaths=1 potions=2 + 복사 배열 zone(2): [0]=40.0/24 [1]=-1.0/11 · loot(10): 0 5 2 0 0 0 0 0 0 0 + 본문 = 시간 종료 | 총 시간 : 04:01 | 보스 처치 : 미처치 | 처치 수 : 29 (보스 0) | 엘리트 처치 : 0 | 최대 연쇄 : 7 | 최대 피해 : 8889 | 레벨 : 11 → 12 | 획득 : 5 2 | 골드 : 777 | 사망 / 물약 : 1 / 2 | + 설정 enabled=True result=True 공용팝업=False abandon표시=False 부활우선=True box=(860.00, 1080.00) 행수=10 합산피해=True · [813q2] 사망중미룸=True 런밖즉시부활=True 전용캔버스=True(40→20) 재정렬=True/True 엘리트UI집계=True 보스HP UI=True + + +① 결과 = 20 PASS / 0 FAIL + +→ E:\NerdNavis\WL_wt\gameplay2\Logs\WL813q2_overlap.txt + +=== WL-813q2 ② 워치독 자동 부활 경로에서도 「부활 우선」이 걸리는가 (813z §1) === +워치독 SO 실측: reviveWatchdogSeconds=20.0s + PASS 부활 대기(DeathFlow=AwaitingRevive) + PASS 결과 미룸 — 이유 = DeathFlow.AwaitingRevive (ReviveDialog.Busy=False 이라 813q 판정만으로는 못 막는다) + PASS 워치독 직전까지 계속 미룸(defer=2) + 워치독: WatchdogCount=1 대기 20.02s · auto 부활=1 · RunResultUI.Revived=1 + PASS 워치독 자동 부활 1회 · 상태 None + PASS RunResultUI 가 Revived 를 받았다(워치독 경로 포함) + PASS 워치독 부활 뒤 결과 화면 표시(shows=1) + +C8 롤백 — resultDeferWhileDying=0 (813q 동작 복귀) + PASS DeathFlow=AwaitingRevive 인데 부활우선=False (스위치 off = 813q 판정만) + +② 결과 = 7 PASS / 0 FAIL + +→ E:\NerdNavis\WL_wt\gameplay2\Logs\WL813q2_watchdog.txt + +=== WL-813q2 ③ D-23 「다음 런」 뒤 타이머 색 · 보스 바 · 배너 리셋 === +RunHud Bind: 연결 IngameUIs/WL_HUD · pos(0.00, -483.56) size(995.56, 167.11) 칸=0 unitsPerPx=1.7778 +SO 색 실측: 평상 #F2F5FA · 경고 #FF4A47 (경고 구간 30s) + PASS 런 1 시작 타이머 색 #F2F5FA = 평상색 · 표시 "04:00" + PASS 남은 25 s → 경고색 #FF4A47 · 표시 "00:25" + PASS 🔴 D-23 — 다음 런 시작 타이머 색 #F2F5FA = 평상색 · 표시 "04:00" (색 쓰기 3회) + PASS 존 게이지 리셋 — 칸 2개 전부 fill 0 + +C8 롤백 — timerResetColorOnRunStart=0 (813q 동작 = D-23 재현) + PASS 스위치 off → 이전 런의 경고색 #FF4A47 이 그대로 남는다 = D-23 원인 지점 확정 + PASS 스위치 on 복귀 → 평상색 + +보스 HP 바 — 이전 런의 바가 남는가 + initialized subscribed=True · layout size=(1564.44, 117.33) unitsPerPx=1.7778 ticks=2 · 늦은구독: 살아 있는 보스 없음(스캔 0) + bound name=테스트 보스 maxHp=200 ratio=1.000 + PASS 바 표시 · ratio=1.000 + PASS 런 단위 보스 HP 실측값 = 37.0% (결과 화면이 읽는 값) + RunStarted 뒤: 리셋 1회 · visible=False · 런 HP=-1.00 · 늦은구독 스캔=0 + PASS RunStarted 를 받아 리셋했다 + PASS 🔴 D-23 — 이전 런의 보스 바가 사라졌다(visible=False) + PASS 런 단위 보스 HP 값도 비워졌다(-1.00) + PASS C8 롤백 bossResetOnRunStart=0 → 813q 동작(바가 남는다 · visible=True) + +보스 등장 배너 — 재트리거 가드가 다음 런에서 풀리는가 + initialized subscribed=True · layout banner pos(0.00, -533.33) size(1528.89, 266.67) unitsPerPx=1.7778 warnThickness=320.0 · 늦은구독: 살아 있는 보스 없음(스캔 0) + shown name=테스트 보스 banner=1.20s warn=0.80s + 같은 런에서 재트리거: 재트리거 억제(guard 3.0s) name=테스트 보스 + PASS 같은 런에서는 가드가 막는다(shows=1) + PASS 다음 런 시작 → 배너 숨김(alpha=0.00) + 다음 런에서 재트리거: shown name=테스트 보스 banner=1.20s warn=0.80s + PASS 다음 런에서는 다시 뜬다 = 가드 해제됨(shows=2) + +[RunHud] 구독=True bound=True visible=True · started=7 tick=2 zoneCleared=0 gate=0 boss=0 ended=1 + 타이머 "04:00" (남은 240.0s · 경고=False · 쓰기 9회) · 단계 "존 1" (쓰기 9회) + [813q2] 타이머 색 = F2F5FA (색 쓰기 8회 · 다음런리셋대기=False · 기본 F2F5FA / 경고 FF4A47) + 존 칸 2 (fill 쓰기 18회): [0]=0.00×1.00 [1]=0.00×1.00 + 노드 pos(0.00, -483.56) size(995.56, 216.89) active=True 부모=WL_HUD SafeArea=False + edge = LiberationSans SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.280 outlineC=0D0805FF underlay=000000CC off(0.50,-0.50) soft=0.22 UNDERLAY_ON=True + 설정 enabled=True hud=True topMargin=272px width=560px timerPx=56 warn=30s pulse=1.35×0.3s + + +③ 결과 = 15 PASS / 0 FAIL + +→ E:\NerdNavis\WL_wt\gameplay2\Logs\WL813q2_reset.txt + +=== WL-813q2 ④ 결과 화면 행 — 보스 미처치 문구 · 엘리트 처치 수 === +SO 행 배열 = 10개 · 엘리트 행 포함=True + PASS 엘리트 처치 행이 SO 배열에 있다 + PASS 런 중 엘리트 처치 3건(일반 5 · 보스 1 은 세지 않는다) + PASS 런 밖 엘리트 처치는 무시(3) + 보스 처치 행 = "미처치" · 엘리트 처치 행 = "3" + PASS 보스 미처치(HP 모름) → "미처치" + PASS 엘리트 처치 행 = "3" (RunEnded 순간 스냅샷) + 바 실측 HP 43.6% 일 때 보스 처치 행 = "미처치 (HP 남음 44 %)" + PASS 보스 미처치 + HP 남음 44 % 표기 — "미처치 (HP 남음 44 %)" + PASS 보스 처치 런 = "02:34" (mm:ss 그대로 · 813q 동작 불변) + PASS C8 롤백 — 보스 행 "미처치" · 엘리트 행 "0" + +[RunResultUI] 구독=True bound=True open=True pending=False · ended=1 shows=1 defer=0 next=0 close=0 abandon생략=0 버퍼증설=0 공용팝업=False + 제목 "시간 종료" · 행 10개 · 부활팝업Busy=False + [813q2] 부활우선=False(없음) · DeathFlow=None 런=Ended(running=False) · died=0 request=0 revived=0 팝업생략부활=0 · 재정렬=False(내림 0/복귀 0) sortingOrder=40 sibling=2 + [813q2] 엘리트 런중=0 종료스냅=0 전체본것=0 · 보스HP(UI 실측)=43.6% · 마지막 사망 로그 = + - TotalTime : "04:00" + - BossClear : "미처치" + - Kills : "29 (보스 0)" + - EliteKills : "0" + - MaxChain : "7" + - MaxDamage : "8889" + - Level : "11 → 12" + - LootByGrade : "5 2" + - Gold : "777" + - DeathsPotions : "1 / 2" + 복사본 outcome=Timeout runIndex=1 total=240.0s bossClear=-1.0s kills=29(보스 0) chain=7 dmg=1234.4/8888.5 lv=11→12 lootTotal=7 gold=777 deaths=1 potions=2 + 복사 배열 zone(2): [0]=40.0/24 [1]=-1.0/11 · loot(10): 0 5 2 0 0 0 0 0 0 0 + 본문 = 시간 종료 | 총 시간 : 04:00 | 보스 처치 : 미처치 | 처치 수 : 29 (보스 0) | 엘리트 처치 : 0 | 최대 연쇄 : 7 | 최대 피해 : 8889 | 레벨 : 11 → 12 | 획득 : 5 2 | 골드 : 777 | 사망 / 물약 : 1 / 2 | + 설정 enabled=True result=True 공용팝업=False abandon표시=False 부활우선=True box=(860.00, 1080.00) 행수=10 합산피해=True · [813q2] 사망중미룸=True 런밖즉시부활=True 전용캔버스=True(40→20) 재정렬=True/True 엘리트UI집계=True 보스HP UI=True + + +④ 결과 = 8 PASS / 0 FAIL + +→ E:\NerdNavis\WL_wt\gameplay2\Logs\WL813q2_rows.txt + +=== WL-813q2 ⑤ GC 0 · C8 === + PASS 같은 초 100틱 — 타이머 쓰기 0회 · 색 쓰기 0회 + PASS Δ GetTotalMemory = 0 B + PASS Killed 100건(엘리트 50) Δ = 0 B · 집계=50 + PASS 재정렬 왕복 100회(내림 100 / 복귀 100) · Δ = 0 B(진단 문자열분) + PASS 노드 재생성 0 — Canvas 1개 · GraphicRaycaster 1개 + + PASS C8 RuntimeDisabled — 결과 화면 표시 0(open=False) + PASS C8 중 부활우선 판정도 무동작(없음) + PASS C8 복구 — Enabled=True + +⑤ 결과 = 8 PASS / 0 FAIL + +→ E:\NerdNavis\WL_wt\gameplay2\Logs\WL813q2_gc.txt + +====================================================== +WL-813q2 전체 = 58 PASS / 0 FAIL diff --git a/AgentScripts/WL813q2_Probe.cs b/AgentScripts/WL813q2_Probe.cs new file mode 100644 index 000000000..9e1fe414e --- /dev/null +++ b/AgentScripts/WL813q2_Probe.cs @@ -0,0 +1,700 @@ +// WL-813q2 프로브 — 결과 화면 핫픽스 실측(에디트 모드 · Play 0 · #813). +// run_script --file AgentScripts/WL813q2_Probe.cs --entry WL813q2_Probe. +// Overlap · Watchdog · Reset · Rows · Gc · All +// 로그는 /Logs/WL813q2_*.txt (Assets 밖 · 미추적). +// +// 🔴 에디트 모드에서 `Time.timeScale` 에 쓰지 않는다(ProjectSettings/TimeManager.asset 오염 · 811b-fix). +// DeathFlow.HoldWorld 도 `Application.isPlaying` 가드가 있어 에디트 모드에선 대입하지 않는다. +// 🔴 씬·프리팹을 저장하지 않는다. 만든 노드는 finally 에서 전부 DestroyImmediate. +// 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복한다(AssetDatabase.SaveAssets 호출 0). +// 🔴 Gameplay 는 읽기·구독·공개 API 호출만 한다(파일 수정 0). `RunDirector.s_phase` 는 리플렉션 **읽기/쓰기**로 +// "런 중/런 밖"만 흉내 낸다(런 시작·종료 부작용을 피하려고 StartRun/EndRun 을 쓰지 않는다). + +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 WL813q2_Probe +{ + const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic; + const BindingFlags SP = BindingFlags.Static | BindingFlags.NonPublic; + + static int s_pass, s_fail; + + // ─────────────────────────────────────────────────────────── 공통 + + static string LogDir + { + get + { + string d = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Logs"); + if (!Directory.Exists(d)) Directory.CreateDirectory(d); + return d; + } + } + + static string Write(string name, string body) + { + string p = Path.Combine(LogDir, name); + File.WriteAllText(p, body, new UTF8Encoding(false)); + return p; + } + + static string Chk(StringBuilder sb, bool ok, string what) + { + if (ok) s_pass++; else s_fail++; + sb.AppendLine((ok ? " PASS " : " **FAIL** ") + what); + return what; + } + + /// 세로 1080×1920 기준 합성 캔버스 + `IngameUIs/WL_HUD` 대역(SO 경로와 같게). + static Transform BuildUiRoot(out GameObject root) + { + root = new GameObject("__WL813q2_Canvas", typeof(RectTransform)); + root.hideFlags = HideFlags.DontSave; + var canvas = root.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + var scaler = root.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1920f, 1080f); + scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand; + root.AddComponent(); + + var ing = new GameObject("IngameUIs", typeof(RectTransform)).GetComponent(); + ing.gameObject.hideFlags = HideFlags.DontSave; + ing.SetParent(root.transform, false); + var hud = new GameObject("WL_HUD", typeof(RectTransform)).GetComponent(); + hud.gameObject.hideFlags = HideFlags.DontSave; + hud.SetParent(ing, false); + hud.anchorMin = hud.anchorMax = new Vector2(0.5f, 0.5f); + hud.pivot = new Vector2(0.5f, 0.5f); + hud.sizeDelta = new Vector2(1080f, 1920f); + // 결과 패널이 형제 맨 뒤/맨 앞으로 움직이는 것을 볼 수 있게 미끼 형제 2개 + for (int i = 0; i < 2; i++) + { + var d = new GameObject("Decoy" + i, typeof(RectTransform)).GetComponent(); + d.gameObject.hideFlags = HideFlags.DontSave; + d.SetParent(hud, false); + } + return root.transform; + } + + /// 813z 프로브와 같은 가짜 메인 PC(사망 상태). + static PCActor MakePC() + { + var go = new GameObject("__WL813q2_PC"); + go.hideFlags = HideFlags.DontSave; + var pc = go.AddComponent(); + pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None; + var stat = new ActorStatInfo(eRole.PC); + stat.Set_Stat(eStat.MaxHP, 1000d); stat.Set_Stat(eStat.HP, 0d); + typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat); + typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false); + typeof(Actor).GetField("DeadStatus", NP).SetValue(pc, true); + return pc; + } + + static void SetPhase(RunPhase p) + { + typeof(RunDirector).GetField("s_phase", SP).SetValue(null, p); + } + + static RunPhase GetPhase() + { + return (RunPhase)typeof(RunDirector).GetField("s_phase", SP).GetValue(null); + } + + static void OnReq(in ReviveRequestEvent e) { s_reqSeen++; } + static int s_reqSeen; + + static void Kill(GameObject go) { if (go != null) UnityEngine.Object.DestroyImmediate(go); } + + static RunResult FakeResult(RunOutcome outcome, float total, float bossClear, int kills, int bossKills) + { + return new RunResult + { + outcome = outcome, runIndex = 1, totalSec = total, runSeconds = 240f, + zoneClearSec = new float[] { 40f, -1f }, zoneKills = new int[] { 24, 11 }, + zoneCount = 2, zonesCleared = 1, + bossGateSec = -1f, bossStartSec = -1f, bossClearSec = bossClear, + kills = kills, bossKills = bossKills, maxChain = 7, + maxDamage = 1234.4, maxDamageBurst = 8888.5, + levelFrom = 11, levelTo = 12, + lootByGrade = new int[] { 0, 5, 2, 0, 0, 0, 0, 0, 0, 0 }, lootTotal = 7, gold = 777, + deaths = 1, potionsUsed = 2, skillCasts = 9, + time = Time.unscaledTime, frame = Time.frameCount, + }; + } + + // ────────────────────────────────────────── ① D-18 결과 위 부활 겹침 + + public static string Overlap() + { + s_pass = s_fail = 0; + var sb = new StringBuilder(); + sb.AppendLine("=== WL-813q2 ① D-18 결과 화면 위 부활 팝업 겹침 (에디트 모드 · Play 0) ==="); + + var ui = WLRunUiSettings.Instance; + var sv = WLSurvivalSettings.Instance; + if (ui == null || sv == null) { string b = sb.AppendLine("**FAIL** 설정 에셋 없음").ToString(); return b + "\n→ " + Write("WL813q2_overlap.txt", b); } + + GameObject root = null; PCActor pc = null; + var phase0 = GetPhase(); + int ov0 = WLSurvivalSettings.RuntimeOverride; + var ovr0 = DeathFlow.ReviveOverride; + bool skip0 = ui.skipRevivePopupAfterRunEnd, dying0 = ui.resultDeferWhileDying, lower0 = ui.resultLowerWhileRevive; + + sb.AppendLine("근거: Q5 캡처 PD2_07 — 제목 \"시간 종료\"(= 결과가 **먼저** 떴다) 위에 부활 팝업이 겹쳐 4행이 가려졌다."); + sb.AppendLine("실측 전제: 813z 부활 팝업 sortingOrder = " + sv.reviveSortingOrder + + " · 결과 화면 SO sortingOrder = " + ui.resultSortingOrder + "(부활 중 " + ui.resultSortingOrderWhileRevive + ")"); + sb.AppendLine(); + + try + { + WLSurvivalSettings.RuntimeOverride = 1; + DeathFlow.ReviveOverride = (p, r) => { }; + DeathFlow.ReviveRequested.Add(OnReq); + s_reqSeen = 0; + + var uiRoot = BuildUiRoot(out root); + sb.AppendLine("Bind: " + RunResultUI.Bind(uiRoot)); + RunResultUI.Subscribe(true); + RunResultUI.ResetState(); + DeathFlow.ResetDiagnostics(); + pc = MakePC(); + + Chk(sb, RunResultUI.Bound, "결과 화면 노드 조립됨"); + Chk(sb, RunResultUI.SortingOrder == ui.resultSortingOrder, + "전용 Canvas sortingOrder = " + RunResultUI.SortingOrder + " (SO " + ui.resultSortingOrder + + ") < 부활 " + sv.reviveSortingOrder); + + // ── A. 캡처와 같은 순서: RunEnded(결과 표시) → 그 뒤 사망 ──────── + sb.AppendLine(); + sb.AppendLine("A. 캡처 순서 재현 — RunEnded → 결과 표시 → **그 뒤** 사망(런 밖)"); + SetPhase(RunPhase.Zones); + var r = FakeResult(RunOutcome.Timeout, 241f, -1f, 29, 0); + SetPhase(RunPhase.Ended); // EndRun 과 같은 상태(IsRunning=false) + WLRunUiProbeHooks.RaiseRunEnded(in r); + Chk(sb, RunResultUI.IsOpen, "RunEnded 로 결과 화면이 떴다(open=" + RunResultUI.IsOpen + " 제목 \"" + RunResultUI.TitleText + "\")"); + Chk(sb, !RunDirector.IsRunning, "런 밖 상태(IsRunning=" + RunDirector.IsRunning + ")"); + + float t = 1000f; + DeathFlow.Tick(t); + DeathFlow.OnPCDied(pc); // ← 결과가 떠 있는 상태에서 사망 + sb.AppendLine(" 사망 직후: DeathFlow=" + DeathFlow.State + " · 즉시부활 호출 " + RunResultUI.SkipPopupReviveCount + + "회 · " + RunResultUI.LastDeathLog); + Chk(sb, RunResultUI.SkipPopupReviveCount >= 1, "런 밖 사망 → 팝업 생략 즉시 부활을 불렀다(skipRevivePopupAfterRunEnd=1)"); + + int reqBefore = s_reqSeen; + DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); // 연출 끝 → acceptEarly 로 바로 부활 + sb.AppendLine(" 연출 끝: DeathFlow=" + DeathFlow.State + " · ReviveRequested " + (s_reqSeen - reqBefore) + + "회(팝업이 1프레임 안에 닫힌다) · Revived=" + RunResultUI.RevivedSeen); + Chk(sb, DeathFlow.State == DeathState.None, "부활 완료 — DeathFlow.State=None(부활 대기 0 = 팝업이 남지 않는다)"); + Chk(sb, DeathFlow.ReviveCount >= 1, "DeathFlow.ReviveCount=" + DeathFlow.ReviveCount); + Chk(sb, RunResultUI.IsOpen && !RunResultUI.Lowered, + "결과 화면은 그대로 · 재정렬 없음(open=" + RunResultUI.IsOpen + " lowered=" + RunResultUI.Lowered + ")"); + + // ── B. 스위치를 끄면 813q 동작(팝업이 뜬다) → 재정렬로 막는다 ── + sb.AppendLine(); + sb.AppendLine("B. skipRevivePopupAfterRunEnd=0 (C8 롤백) — 팝업이 뜨는 경우 결과를 부활 **뒤로 재정렬**"); + ui.skipRevivePopupAfterRunEnd = false; + DeathFlow.ResetDiagnostics(); + RunResultUI.ResetState(); + SetPhase(RunPhase.Ended); + WLRunUiProbeHooks.RaiseRunEnded(in r); + Chk(sb, RunResultUI.IsOpen, "결과 화면 재표시(open=" + RunResultUI.IsOpen + ")"); + int sib0 = RunResultUI.SiblingIndex; + + t = 2000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); + sb.AppendLine(" 사망 후: " + RunResultUI.LastDeathLog); + Chk(sb, RunResultUI.SkipPopupReviveCount == 0, "즉시 부활 호출 0(스위치 off = 813q 동작)"); + Chk(sb, RunResultUI.Lowered && RunResultUI.SortingOrder == ui.resultSortingOrderWhileRevive, + "결과를 부활 뒤로 재정렬 sortingOrder " + ui.resultSortingOrder + " → " + RunResultUI.SortingOrder + + " (부활 " + sv.reviveSortingOrder + " 아래)"); + Chk(sb, RunResultUI.SiblingIndex == 0 && sib0 != 0, + "형제 순서 " + sib0 + " → " + RunResultUI.SiblingIndex + " (SetAsFirstSibling)"); + + DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); // AwaitingRevive + Chk(sb, DeathFlow.State == DeathState.AwaitingRevive, "부활 대기 상태(DeathFlow=" + DeathFlow.State + ")"); + Chk(sb, RunResultUI.ReviveBusy, "「부활 우선」 판정 ON — 이유 = " + RunResultUI.ReviveBusyReason); + + DeathFlow.Revive(); + Chk(sb, !RunResultUI.Lowered && RunResultUI.SortingOrder == ui.resultSortingOrder, + "부활 완료 → 결과 화면 복귀 sortingOrder=" + RunResultUI.SortingOrder + " 형제=" + RunResultUI.SiblingIndex); + + // ── C. 813q 원래 판정(ReviveDialog.Busy)만으로는 못 잡는다는 증명 ── + sb.AppendLine(); + sb.AppendLine("C. 왜 813q 판정이 못 잡았나 — RunEnded 시점에는 아무도 죽지 않아 Busy=false 였다"); + DeathFlow.ResetDiagnostics(); + RunResultUI.ResetState(); + SetPhase(RunPhase.Ended); + Chk(sb, !ReviveDialog.Busy && DeathFlow.State == DeathState.None, + "RunEnded 직전 실측: ReviveDialog.Busy=" + ReviveDialog.Busy + " · DeathFlow=" + DeathFlow.State + + " → 813q 의 미룸 조건은 어느 쪽으로도 걸리지 않는다"); + ui.skipRevivePopupAfterRunEnd = true; + + // ── D. 반대 순서(사망 먼저 → RunEnded)는 813q2 에서도 미뤄진다 ── + sb.AppendLine(); + sb.AppendLine("D. 반대 순서 — 사망(런 중) → RunEnded → 부활 뒤에 결과가 뜬다"); + RunResultUI.ResetState(); + DeathFlow.ResetDiagnostics(); + SetPhase(RunPhase.Zones); + t = 3000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); + Chk(sb, DeathFlow.State == DeathState.Dying, "사망 연출 중(DeathFlow=" + DeathFlow.State + ")"); + Chk(sb, RunResultUI.ReviveBusy, "「부활 우선」 ON — " + RunResultUI.ReviveBusyReason); + SetPhase(RunPhase.Ended); + WLRunUiProbeHooks.RaiseRunEnded(in r); + Chk(sb, !RunResultUI.IsOpen && RunResultUI.Pending, + "결과 화면 미룸(open=" + RunResultUI.IsOpen + " pending=" + RunResultUI.Pending + + " defer=" + RunResultUI.DeferredCount + ")"); + DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); + DeathFlow.Revive(); + RunResultUI.Tick(Time.unscaledTime); + Chk(sb, RunResultUI.IsOpen && !RunResultUI.Pending, + "부활 뒤 결과 화면 표시(open=" + RunResultUI.IsOpen + " shows=" + RunResultUI.ShownCount + ")"); + + sb.AppendLine(); + sb.AppendLine(RunResultUI.Dump()); + } + catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } + finally + { + ui.skipRevivePopupAfterRunEnd = skip0; ui.resultDeferWhileDying = dying0; ui.resultLowerWhileRevive = lower0; + DeathFlow.ReviveRequested.Remove(OnReq); + DeathFlow.ReviveOverride = ovr0; + DeathFlow.Abort("probe cleanup"); + WLSurvivalSettings.RuntimeOverride = ov0; + RunResultUI.Teardown(); + Kill(pc != null ? pc.gameObject : null); + Kill(root); + SetPhase(phase0); + } + + sb.AppendLine(); + sb.AppendLine("① 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); + string body = sb.ToString(); + return body + "\n→ " + Write("WL813q2_overlap.txt", body); + } + + // ────────────────────────────────────── ② 워치독 자동 부활 경로(813z) + + public static string Watchdog() + { + s_pass = s_fail = 0; + var sb = new StringBuilder(); + sb.AppendLine("=== WL-813q2 ② 워치독 자동 부활 경로에서도 「부활 우선」이 걸리는가 (813z §1) ==="); + + var ui = WLRunUiSettings.Instance; + var sv = WLSurvivalSettings.Instance; + if (ui == null || sv == null) { string b = sb.AppendLine("**FAIL** 설정 에셋 없음").ToString(); return b + "\n→ " + Write("WL813q2_watchdog.txt", b); } + + GameObject root = null; PCActor pc = null; + var phase0 = GetPhase(); + int ov0 = WLSurvivalSettings.RuntimeOverride; + var ovr0 = DeathFlow.ReviveOverride; + bool dying0 = ui.resultDeferWhileDying; + + try + { + WLSurvivalSettings.RuntimeOverride = 1; + DeathFlow.ReviveOverride = (p, r) => { }; + DeathFlow.ReviveRequested.Add(OnReq); + s_reqSeen = 0; + + var uiRoot = BuildUiRoot(out root); + RunResultUI.Bind(uiRoot); + RunResultUI.Subscribe(true); + RunResultUI.ResetState(); + DeathFlow.ResetDiagnostics(); + pc = MakePC(); + + sb.AppendLine("워치독 SO 실측: reviveWatchdogSeconds=" + sv.reviveWatchdogSeconds.ToString("F1") + "s"); + SetPhase(RunPhase.Zones); + float t = 5000f; + DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); + float req = t + sv.deathDisplaySeconds + 0.01f; + DeathFlow.Tick(req); + Chk(sb, DeathFlow.State == DeathState.AwaitingRevive, "부활 대기(DeathFlow=" + DeathFlow.State + ")"); + + // 런 종료가 부활 대기 중에 온다 → 결과는 미뤄진다(813q 는 Busy 로만 봤다) + SetPhase(RunPhase.Ended); + var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 31, 0); + WLRunUiProbeHooks.RaiseRunEnded(in r); + Chk(sb, !RunResultUI.IsOpen && RunResultUI.Pending, + "결과 미룸 — 이유 = " + RunResultUI.ReviveBusyReason + " (ReviveDialog.Busy=" + ReviveDialog.Busy + + " 이라 813q 판정만으로는 못 막는다)"); + + // 워치독 시각 직전/직후 + DeathFlow.Tick(req + sv.reviveWatchdogSeconds - 0.1f); + RunResultUI.Tick(Time.unscaledTime); + Chk(sb, !RunResultUI.IsOpen, "워치독 직전까지 계속 미룸(defer=" + RunResultUI.DeferredCount + ")"); + + DeathFlow.Tick(req + sv.reviveWatchdogSeconds + 0.02f); + sb.AppendLine(" 워치독: WatchdogCount=" + DeathFlow.WatchdogCount + " 대기 " + DeathFlow.LastWatchdogSeconds.ToString("F2") + + "s · auto 부활=" + DeathFlow.AutoReviveCount + " · RunResultUI.Revived=" + RunResultUI.RevivedSeen); + Chk(sb, DeathFlow.WatchdogCount == 1 && DeathFlow.State == DeathState.None, "워치독 자동 부활 1회 · 상태 None"); + Chk(sb, RunResultUI.RevivedSeen >= 1, "RunResultUI 가 Revived 를 받았다(워치독 경로 포함)"); + RunResultUI.Tick(Time.unscaledTime); + Chk(sb, RunResultUI.IsOpen && !RunResultUI.Pending, "워치독 부활 뒤 결과 화면 표시(shows=" + RunResultUI.ShownCount + ")"); + + // C8 — resultDeferWhileDying=0 이면 813q 동작(미룸 없음) + sb.AppendLine(); + sb.AppendLine("C8 롤백 — resultDeferWhileDying=0 (813q 동작 복귀)"); + ui.resultDeferWhileDying = false; + RunResultUI.ResetState(); DeathFlow.ResetDiagnostics(); + SetPhase(RunPhase.Zones); + t = 6000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); + DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); + Chk(sb, DeathFlow.State == DeathState.AwaitingRevive && !RunResultUI.ReviveBusy, + "DeathFlow=" + DeathFlow.State + " 인데 부활우선=" + RunResultUI.ReviveBusy + " (스위치 off = 813q 판정만)"); + DeathFlow.Revive(); + } + catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } + finally + { + ui.resultDeferWhileDying = dying0; + DeathFlow.ReviveRequested.Remove(OnReq); + DeathFlow.ReviveOverride = ovr0; + DeathFlow.Abort("probe cleanup"); + WLSurvivalSettings.RuntimeOverride = ov0; + RunResultUI.Teardown(); + Kill(pc != null ? pc.gameObject : null); + Kill(root); + SetPhase(phase0); + } + + sb.AppendLine(); + sb.AppendLine("② 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); + string body2 = sb.ToString(); + return body2 + "\n→ " + Write("WL813q2_watchdog.txt", body2); + } + + // ─────────────────────────────── ③ D-23 「다음 런」 뒤 상태 리셋 + + public static string Reset() + { + s_pass = s_fail = 0; + var sb = new StringBuilder(); + sb.AppendLine("=== WL-813q2 ③ D-23 「다음 런」 뒤 타이머 색 · 보스 바 · 배너 리셋 ==="); + + var ui = WLRunUiSettings.Instance; + if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_reset.txt", b); } + + GameObject root = null, barGo = null, bannerGo = null; + bool reset0 = ui.timerResetColorOnRunStart; + var phase0 = GetPhase(); + + try + { + var uiRoot = BuildUiRoot(out root); + sb.AppendLine("RunHud Bind: " + RunHud.Bind(uiRoot)); + RunHud.Subscribe(true); + RunHud.ResetState(); + + string warnHex = ColorUtility.ToHtmlStringRGB(ui.timerWarnColor); + string normHex = ColorUtility.ToHtmlStringRGB(ui.timerColor); + sb.AppendLine("SO 색 실측: 평상 #" + normHex + " · 경고 #" + warnHex + " (경고 구간 " + ui.timerWarnSeconds + "s)"); + + // 런 1 — 시작 → 마지막 30 s 경고색 + WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe"); + Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, + "런 1 시작 타이머 색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " = 평상색 · 표시 \"" + RunHud.TimerText + "\""); + WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 1, 3, 14, 1, 2, RunPhase.Zones, 1); + Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == warnHex && RunHud.TimerWarn, + "남은 25 s → 경고색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " · 표시 \"" + RunHud.TimerText + "\""); + + var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0); + WLRunUiProbeHooks.RaiseRunEnded(in r); + + // 런 2 (= 「다음 런」) — 색이 평상색으로 돌아와야 한다 + WLRunUiProbeHooks.RaiseRunStarted(2, 240f, 2, "restart"); + Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, + "🔴 D-23 — 다음 런 시작 타이머 색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + + " = 평상색 · 표시 \"" + RunHud.TimerText + "\" (색 쓰기 " + RunHud.TimerColorWrites + "회)"); + bool zonesZero = true; + for (int i = 0; i < RunHud.ZoneCells; i++) if (RunHud.ZoneFill(i) > 0.0005f) zonesZero = false; + Chk(sb, zonesZero && RunHud.ZoneCells == 2, "존 게이지 리셋 — 칸 " + RunHud.ZoneCells + "개 전부 fill 0"); + + // C8 — 스위치를 끄면 813q 버그가 그대로 재현된다(= 이 스위치가 원인 지점이다) + sb.AppendLine(); + sb.AppendLine("C8 롤백 — timerResetColorOnRunStart=0 (813q 동작 = D-23 재현)"); + ui.timerResetColorOnRunStart = false; + WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 1, 3, 14, 1, 2, RunPhase.Zones, 2); + WLRunUiProbeHooks.RaiseRunStarted(3, 240f, 2, "restart"); + Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == warnHex, + "스위치 off → 이전 런의 경고색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + + " 이 그대로 남는다 = D-23 원인 지점 확정"); + ui.timerResetColorOnRunStart = true; + WLRunUiProbeHooks.RaiseRunStarted(4, 240f, 2, "restart"); + Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, "스위치 on 복귀 → 평상색"); + + // ── 보스 HP 바 + sb.AppendLine(); + sb.AppendLine("보스 HP 바 — 이전 런의 바가 남는가"); + barGo = new GameObject("__WL813q2_BossBar", typeof(RectTransform)); + barGo.hideFlags = HideFlags.DontSave; + barGo.transform.SetParent(root.transform, false); + var bar = barGo.AddComponent(); + sb.AppendLine(" " + bar.Initialize()); + sb.AppendLine(" " + bar.Bind(null, "테스트 보스", 200f)); // 합성(actor=null) 경로 + Chk(sb, bar.Visible && bar.LastRatio > 0.99f, "바 표시 · ratio=" + bar.LastRatio.ToString("F3")); + bar.SetRatio(0.37f); + Chk(sb, Mathf.Abs(BossHpBar.RunBossHpRatio - 0.37f) < 0.001f, + "런 단위 보스 HP 실측값 = " + (BossHpBar.RunBossHpRatio * 100f).ToString("F1") + "% (결과 화면이 읽는 값)"); + + int reset1 = BossHpBar.RunResetCount; + WLRunUiProbeHooks.RaiseRunStarted(5, 240f, 2, "restart"); + sb.AppendLine(" RunStarted 뒤: 리셋 " + (BossHpBar.RunResetCount - reset1) + "회 · visible=" + bar.Visible + + " · 런 HP=" + BossHpBar.RunBossHpRatio.ToString("F2") + " · 늦은구독 스캔=" + bar.LateScanCount); + Chk(sb, BossHpBar.RunResetCount == reset1 + 1, "RunStarted 를 받아 리셋했다"); + Chk(sb, !bar.Visible, "🔴 D-23 — 이전 런의 보스 바가 사라졌다(visible=" + bar.Visible + ")"); + Chk(sb, BossHpBar.RunBossHpRatio < 0f, "런 단위 보스 HP 값도 비워졌다(" + BossHpBar.RunBossHpRatio.ToString("F2") + ")"); + + ui.bossResetOnRunStart = false; + bar.Bind(null, "테스트 보스", 200f); + WLRunUiProbeHooks.RaiseRunStarted(6, 240f, 2, "restart"); + Chk(sb, bar.Visible, "C8 롤백 bossResetOnRunStart=0 → 813q 동작(바가 남는다 · visible=" + bar.Visible + ")"); + ui.bossResetOnRunStart = true; + + // ── 보스 배너(재트리거 가드) + sb.AppendLine(); + sb.AppendLine("보스 등장 배너 — 재트리거 가드가 다음 런에서 풀리는가"); + bannerGo = new GameObject("__WL813q2_BossBanner", typeof(RectTransform)); + bannerGo.hideFlags = HideFlags.DontSave; + bannerGo.transform.SetParent(root.transform, false); + var banner = bannerGo.AddComponent(); + sb.AppendLine(" " + banner.Initialize()); + sb.AppendLine(" " + banner.Trigger("테스트 보스", true)); + int show1 = banner.ShowCount; + sb.AppendLine(" 같은 런에서 재트리거: " + banner.Trigger("테스트 보스", true)); + Chk(sb, banner.ShowCount == show1, "같은 런에서는 가드가 막는다(shows=" + banner.ShowCount + ")"); + WLRunUiProbeHooks.RaiseRunStarted(7, 240f, 2, "restart"); + Chk(sb, !banner.Visible, "다음 런 시작 → 배너 숨김(alpha=" + banner.BannerAlpha.ToString("F2") + ")"); + sb.AppendLine(" 다음 런에서 재트리거: " + banner.Trigger("테스트 보스", true)); + Chk(sb, banner.ShowCount == show1 + 1, "다음 런에서는 다시 뜬다 = 가드 해제됨(shows=" + banner.ShowCount + ")"); + + sb.AppendLine(); + sb.AppendLine(RunHud.Dump()); + } + catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } + finally + { + ui.timerResetColorOnRunStart = reset0; + ui.bossResetOnRunStart = true; + BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; + RunHud.Teardown(); + Kill(barGo); Kill(bannerGo); Kill(root); + SetPhase(phase0); + } + + sb.AppendLine(); + sb.AppendLine("③ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); + string body3 = sb.ToString(); + return body3 + "\n→ " + Write("WL813q2_reset.txt", body3); + } + + // ─────────────────────────────── ④ 결과 화면 행 보강(보스 미처치 · 엘리트) + + public static string Rows() + { + s_pass = s_fail = 0; + var sb = new StringBuilder(); + sb.AppendLine("=== WL-813q2 ④ 결과 화면 행 — 보스 미처치 문구 · 엘리트 처치 수 ==="); + + var ui = WLRunUiSettings.Instance; + if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_rows.txt", b); } + + GameObject root = null, barGo = null; + var phase0 = GetPhase(); + + try + { + var uiRoot = BuildUiRoot(out root); + RunResultUI.Bind(uiRoot); + RunResultUI.Subscribe(true); + RunResultUI.ResetState(); + BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; // 앞 항목의 잔값 제거 + + sb.AppendLine("SO 행 배열 = " + ui.resultRows.Length + "개 · 엘리트 행 포함=" + ui.HasRow(WLRunResultRow.EliteKills)); + Chk(sb, ui.HasRow(WLRunResultRow.EliteKills), "엘리트 처치 행이 SO 배열에 있다"); + + // 엘리트 집계 — 런 중에만 센다 + SetPhase(RunPhase.Zones); + WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe"); + for (int i = 0; i < 3; i++) WLRunUiProbeHooks.RaiseKilled(eSubRol.Elite); + for (int i = 0; i < 5; i++) WLRunUiProbeHooks.RaiseKilled(eSubRol.None); + WLRunUiProbeHooks.RaiseKilled(eSubRol.Boss); + Chk(sb, RunResultUI.EliteKills == 3, "런 중 엘리트 처치 " + RunResultUI.EliteKills + "건(일반 5 · 보스 1 은 세지 않는다)"); + + SetPhase(RunPhase.Ended); + WLRunUiProbeHooks.RaiseKilled(eSubRol.Elite); // 런 밖 처치는 세지 않는다 + Chk(sb, RunResultUI.EliteKills == 3, "런 밖 엘리트 처치는 무시(" + RunResultUI.EliteKills + ")"); + + // 보스 미처치 + HP 를 모르는 경우 + var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0); + WLRunUiProbeHooks.RaiseRunEnded(in r); + string boss1 = RunResultUI.RowValueText(WLRunResultRow.BossClear); + string elite1 = RunResultUI.RowValueText(WLRunResultRow.EliteKills); + sb.AppendLine(" 보스 처치 행 = \"" + boss1 + "\" · 엘리트 처치 행 = \"" + elite1 + "\""); + Chk(sb, boss1 == ui.resultBossNotClearedText, "보스 미처치(HP 모름) → \"" + boss1 + "\""); + Chk(sb, elite1 == "3", "엘리트 처치 행 = \"" + elite1 + "\" (RunEnded 순간 스냅샷)"); + + // 보스 미처치 + 813c 바가 남긴 HP 실측값 + barGo = new GameObject("__WL813q2_BossBar2", typeof(RectTransform)); + barGo.hideFlags = HideFlags.DontSave; + barGo.transform.SetParent(root.transform, false); + var bar = barGo.AddComponent(); + bar.Initialize(); + bar.Bind(null, "테스트 보스", 200f); + bar.SetRatio(0.436f); + RunResultUI.ResetState(); + WLRunUiProbeHooks.RaiseRunEnded(in r); + string boss2 = RunResultUI.RowValueText(WLRunResultRow.BossClear); + sb.AppendLine(" 바 실측 HP 43.6% 일 때 보스 처치 행 = \"" + boss2 + "\""); + Chk(sb, boss2.Contains("44"), "보스 미처치 + HP 남음 44 % 표기 — \"" + boss2 + "\""); + + // 보스 처치한 런은 시간이 그대로 나온다 + var r2 = FakeResult(RunOutcome.Win, 154f, 154f, 99, 1); + RunResultUI.ResetState(); + WLRunUiProbeHooks.RaiseRunEnded(in r2); + string boss3 = RunResultUI.RowValueText(WLRunResultRow.BossClear); + Chk(sb, boss3 == "02:34", "보스 처치 런 = \"" + boss3 + "\" (mm:ss 그대로 · 813q 동작 불변)"); + + // C8 — UI 집계·UI HP 를 끄면 이전 동작 + ui.resultBossHpFromUi = false; ui.resultCountElitesInUi = false; + RunResultUI.ResetState(); + WLRunUiProbeHooks.RaiseRunEnded(in r); + Chk(sb, RunResultUI.RowValueText(WLRunResultRow.BossClear) == ui.resultBossNotClearedText && + RunResultUI.RowValueText(WLRunResultRow.EliteKills) == "0", + "C8 롤백 — 보스 행 \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\" · 엘리트 행 \"" + + RunResultUI.RowValueText(WLRunResultRow.EliteKills) + "\""); + ui.resultBossHpFromUi = true; ui.resultCountElitesInUi = true; + + sb.AppendLine(); + sb.AppendLine(RunResultUI.Dump()); + } + catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } + finally + { + ui.resultBossHpFromUi = true; ui.resultCountElitesInUi = true; + RunResultUI.Teardown(); + Kill(barGo); Kill(root); + SetPhase(phase0); + } + + sb.AppendLine(); + sb.AppendLine("④ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); + string body4 = sb.ToString(); + return body4 + "\n→ " + Write("WL813q2_rows.txt", body4); + } + + // ────────────────────────────────────────────────── ⑤ GC 0 · C8 + + public static string Gc() + { + s_pass = s_fail = 0; + var sb = new StringBuilder(); + sb.AppendLine("=== WL-813q2 ⑤ GC 0 · C8 ==="); + + var ui = WLRunUiSettings.Instance; + if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_gc.txt", b); } + + GameObject root = null; + var phase0 = GetPhase(); + + try + { + var uiRoot = BuildUiRoot(out root); + RunHud.Bind(uiRoot); RunHud.Subscribe(true); RunHud.ResetState(); + RunResultUI.Bind(uiRoot); RunResultUI.Subscribe(true); RunResultUI.ResetState(); + + SetPhase(RunPhase.Zones); + WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe"); + WLRunUiProbeHooks.RaiseRunTick(60f, 180f, 0, 5, 24, 0, 2, RunPhase.Zones, 1); + + // 같은 초 100틱 = 표시 쓰기 0회 · 델타 0 B + GC.Collect(); GC.WaitForPendingFinalizers(); + long m0 = GC.GetTotalMemory(true); + int tw0 = RunHud.TimerWrites, cw0 = RunHud.TimerColorWrites; + for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseRunTick(60f, 180f, 0, 5, 24, 0, 2, RunPhase.Zones, 1); + long m1 = GC.GetTotalMemory(false); + Chk(sb, RunHud.TimerWrites == tw0 && RunHud.TimerColorWrites == cw0, + "같은 초 100틱 — 타이머 쓰기 " + (RunHud.TimerWrites - tw0) + "회 · 색 쓰기 " + (RunHud.TimerColorWrites - cw0) + "회"); + Chk(sb, m1 - m0 == 0, "Δ GetTotalMemory = " + (m1 - m0) + " B"); + + // Killed 100건(엘리트 집계 경로) — 문자열 0 + GC.Collect(); GC.WaitForPendingFinalizers(); + long k0 = GC.GetTotalMemory(true); + for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseKilled(i % 2 == 0 ? eSubRol.Elite : eSubRol.None); + long k1 = GC.GetTotalMemory(false); + Chk(sb, k1 - k0 == 0, "Killed 100건(엘리트 50) Δ = " + (k1 - k0) + " B · 집계=" + RunResultUI.EliteKills); + + // 재정렬 100회 왕복 — 노드 재생성 0(Canvas·Raycaster 는 1개씩 재사용) + // 🔴 재정렬 함수는 진단 문자열을 돌려주므로 그 자체는 할당이 있다. 다만 **매 프레임 경로가 아니다** + // (사망 1회 · 부활 1회). 여기서는 "노드를 다시 만들지 않는다"만 PASS 기준으로 둔다. + long l0 = GC.GetTotalMemory(true); + for (int i = 0; i < 100; i++) { RunResultUI.LowerBehindRevive(); RunResultUI.RaiseToFront(); } + long l1 = GC.GetTotalMemory(false); + var resRoot = root.transform.Find("IngameUIs/WL_HUD/WL_RunResult"); + int canvasN = resRoot != null ? resRoot.GetComponents().Length : -1; + int rayN = resRoot != null ? resRoot.GetComponents().Length : -1; + Chk(sb, RunResultUI.LowerCount == 100 && RunResultUI.RaiseCount == 100, "재정렬 왕복 100회(내림 " + + RunResultUI.LowerCount + " / 복귀 " + RunResultUI.RaiseCount + ") · Δ = " + (l1 - l0) + " B(진단 문자열분)"); + Chk(sb, canvasN == 1 && rayN == 1, "노드 재생성 0 — Canvas " + canvasN + "개 · GraphicRaycaster " + rayN + "개"); + + // C8 — 전체 off + sb.AppendLine(); + WLRunUiSettings.RuntimeDisabled = true; + RunResultUI.ResetState(); + var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0); + SetPhase(RunPhase.Ended); + WLRunUiProbeHooks.RaiseRunEnded(in r); + RunResultUI.Tick(Time.unscaledTime); + Chk(sb, !RunResultUI.IsOpen, "C8 RuntimeDisabled — 결과 화면 표시 0(open=" + RunResultUI.IsOpen + ")"); + Chk(sb, !RunResultUI.ReviveBusy, "C8 중 부활우선 판정도 무동작(" + RunResultUI.ReviveBusyReason + ")"); + WLRunUiSettings.RuntimeDisabled = false; + Chk(sb, WLRunUiSettings.Enabled, "C8 복구 — Enabled=" + WLRunUiSettings.Enabled); + } + catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } + finally + { + WLRunUiSettings.RuntimeDisabled = false; + RunHud.Teardown(); RunResultUI.Teardown(); + Kill(root); + SetPhase(phase0); + } + + sb.AppendLine(); + sb.AppendLine("⑤ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); + string body5 = sb.ToString(); + return body5 + "\n→ " + Write("WL813q2_gc.txt", body5); + } + + // ───────────────────────────────────────────────────────── 전체 + + public static string All() + { + var sb = new StringBuilder(); + int pass = 0, fail = 0; + string[] parts = new string[5]; + parts[0] = Overlap(); pass += s_pass; fail += s_fail; + parts[1] = Watchdog(); pass += s_pass; fail += s_fail; + parts[2] = Reset(); pass += s_pass; fail += s_fail; + parts[3] = Rows(); pass += s_pass; fail += s_fail; + parts[4] = Gc(); pass += s_pass; fail += s_fail; + for (int i = 0; i < parts.Length; i++) sb.AppendLine(parts[i]).AppendLine(); + sb.AppendLine("======================================================"); + sb.AppendLine("WL-813q2 전체 = " + pass + " PASS / " + fail + " FAIL"); + string body = sb.ToString(); + return "WL-813q2 전체 = " + pass + " PASS / " + fail + " FAIL\n→ " + Write("WL813q2_all.txt", body); + } +} diff --git a/Assets/WL/UI/Scripts/BossBanner.cs b/Assets/WL/UI/Scripts/BossBanner.cs index c529913ea..aacb9fa64 100644 --- a/Assets/WL/UI/Scripts/BossBanner.cs +++ b/Assets/WL/UI/Scripts/BossBanner.cs @@ -26,6 +26,7 @@ using UnityEngine; using UnityEngine.UI; using WL.Combat.Boss; using WL.Combat.Core; +using WL.Combat.Run; namespace WL.UI { @@ -137,14 +138,37 @@ namespace WL.UI { CombatEvents.Spawned.Add(OnSpawned); BossEvents.BossPhase.Add(OnBossPhase); + RunEvents.RunStarted.Add(OnRunStarted); // WL-813q2(D-23) — 다음 런 리셋 } else { CombatEvents.Spawned.Remove(OnSpawned); BossEvents.BossPhase.Remove(OnBossPhase); + RunEvents.RunStarted.Remove(OnRunStarted); } } + // ── WL-813q2(D-23) — 「다음 런」 뒤 배너 상태(재트리거 가드·이름) 리셋 ── + private void OnRunStarted(in RunStartedEvent e) { ResetForNewRun(); } + + /// 런 시작 리셋 — 배너를 숨기고 재트리거 가드·이름을 지운다(같은 보스라도 새 런에서는 다시 뜬다). + public string ResetForNewRun() + { + var ui = WLRunUiSettings.Instance; + if (ui != null && !ui.bossResetOnRunStart) return "런시작리셋: bossResetOnRunStart=false — 생략"; + + RunResetCount++; + _lastTrigger = -999f; _shownName = ""; _nameKnown = false; + LateTriggered = false; + HideNow(); + + if (ui == null || !ui.bossBannerRetriggerOnRunStart) return "런시작리셋: 숨김·가드 해제만"; + return "런시작리셋 → " + TriggerExistingBoss(); + } + + /// 런 시작 리셋 횟수(진단 · D-23). + public static int RunResetCount; + // ── 구독 ────────────────────────────────────────────────────────────── private void OnSpawned(in SpawnedEvent e) { diff --git a/Assets/WL/UI/Scripts/BossHpBar.cs b/Assets/WL/UI/Scripts/BossHpBar.cs index 585fe57aa..cc0e4155e 100644 --- a/Assets/WL/UI/Scripts/BossHpBar.cs +++ b/Assets/WL/UI/Scripts/BossHpBar.cs @@ -25,6 +25,7 @@ using TMPro; using UnityEngine; using UnityEngine.UI; using WL.Combat.Core; +using WL.Combat.Run; namespace WL.UI { @@ -54,6 +55,15 @@ namespace WL.UI private float _hideAt = -1f; private bool _subscribed; + // ── WL-813q2 — 이번 런에서 바가 실제로 본 보스 HP 비율(결과 화면 「보스 처치」 행이 읽는다) ── + // 🔴 813p `RunResult` 에는 보스 HP 필드가 없다(구조체 실측) → 이 값이 UI 자체 실측 원천이다. + // −1 = 이번 런에 보스를 한 번도 물지 않았다. + public static float RunBossHpRatio = -1f; + /// 이번 런에 보스를 한 번이라도 물었는가(진단). + public static bool RunBossSeen; + /// 런 시작 리셋 횟수(진단 · D-23). + public static int RunResetCount; + /// 마지막 갱신 결과 — 프로브(에디터)가 읽는 진단 값. public float LastRatio { get; private set; } public int SpawnedSeen { get; private set; } @@ -145,15 +155,40 @@ namespace WL.UI CombatEvents.Spawned.Add(OnSpawned); CombatEvents.HitConfirmed.Add(OnHitConfirmed); CombatEvents.Killed.Add(OnKilled); + RunEvents.RunStarted.Add(OnRunStarted); // WL-813q2(D-23) — 다음 런 리셋 } else { CombatEvents.Spawned.Remove(OnSpawned); CombatEvents.HitConfirmed.Remove(OnHitConfirmed); CombatEvents.Killed.Remove(OnKilled); + RunEvents.RunStarted.Remove(OnRunStarted); } } + // ── WL-813q2(D-23) — 「다음 런」 뒤 이전 런의 보스 바가 남는다 ───────── + // `RunDirector.Restart()` 는 존 카운터·게이트만 되돌리고 UI 는 건드리지 않는다(Gameplay 소유). + // 그래서 UI 쪽에서 `RunStarted` 를 받아 스스로 리셋하고, 보스가 아직 살아 있으면 + // 813y ⑤ 늦은 구독 경로(`BindExistingBoss`)를 그대로 다시 태워 물린다. + private void OnRunStarted(in RunStartedEvent e) { ResetForNewRun(); } + + /// 런 시작 리셋 — 바를 숨기고 물림을 푼 뒤, 살아 있는 보스가 있으면 다시 물린다. + public string ResetForNewRun() + { + var ui = WLRunUiSettings.Instance; + if (ui != null && !ui.bossResetOnRunStart) return "런시작리셋: bossResetOnRunStart=false — 생략"; + + RunResetCount++; + RunBossHpRatio = -1f; // 결과 화면이 읽는 런 단위 값도 여기서 비운다 + RunBossSeen = false; + LateBound = false; + HideNow(); // _bound=false · _boss=null · alpha 0 + SetRatio(0f); + + if (ui != null && !ui.bossBarRebindOnRunStart) return "런시작리셋: 숨김만(재바인드 off)"; + return "런시작리셋 → " + BindExistingBoss(); + } + private void Update() { if (_hideAt >= 0f && Time.unscaledTime >= _hideAt) HideNow(); @@ -308,6 +343,8 @@ namespace WL.UI { LastRatio = Mathf.Clamp01(ratio); if (fill != null) fill.fillAmount = LastRatio; + // WL-813q2 — 물고 있는 동안만 런 단위 값을 남긴다(숨김·리셋 뒤의 0 은 반영하지 않는다). + if (_bound) { RunBossHpRatio = LastRatio; RunBossSeen = true; } } private void ShowNow() @@ -534,6 +571,9 @@ namespace WL.UI " seen(Spawned/Hit/Killed)=" + SpawnedSeen + "/" + HitSeen + "/" + KilledSeen + " 늦은구독=" + LateBound + "(스캔 " + LateScanCount + " · BossArena.Boss=" + (WL.Combat.Boss.BossArena.Boss != null ? WL.Combat.Boss.BossArena.Boss.name : "없음") + ")"); + sb.AppendLine(" [813q2] 런 리셋 " + RunResetCount + "회 · 런 보스 HP=" + + (RunBossHpRatio >= 0f ? (RunBossHpRatio * 100f).ToString("F1") + "%" : "없음") + + " (본적=" + RunBossSeen + ") · RunStarted 구독=" + RunEvents.RunStarted.Count); sb.AppendLine(" CombatEvents subscribers Spawned=" + CombatEvents.Spawned.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count + " Killed=" + CombatEvents.Killed.Count + " Enabled=" + CombatEvents.Enabled); diff --git a/Assets/WL/UI/Scripts/RunHud.cs b/Assets/WL/UI/Scripts/RunHud.cs index 036d8c9d9..87ce59850 100644 --- a/Assets/WL/UI/Scripts/RunHud.cs +++ b/Assets/WL/UI/Scripts/RunHud.cs @@ -56,6 +56,10 @@ namespace WL.UI private static bool s_visible; private static int s_shownSec = int.MinValue; private static bool s_shownWarn; + // WL-813q2(D-23) — 다음 런에서 타이머 색을 **반드시 한 번** 다시 쓰게 하는 플래그. + // 813q 버그: RunStarted 가 s_shownWarn 을 false 로 되돌리면 WriteTimer 의 + // `warn != s_shownWarn` 이 false 가 되어 색 대입이 통째로 생략된다 → 이전 런의 경고색(붉은색) 잔존. + private static bool s_timerColorDirty; private static int s_shownPhaseKind = -1; // 0 = 존 · 1 = 보스 private static int s_shownPhaseZone = -1; private static float s_runSeconds; @@ -66,7 +70,12 @@ namespace WL.UI // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── public static int StartedSeen, TickSeen, ZoneClearedSeen, GateSeen, BossSeen, EndedSeen; public static int TimerWrites, PhaseWrites, FillWrites; + public static int TimerColorWrites; // WL-813q2(D-23) — 타이머 색을 실제로 쓴 횟수 public static string LastEdge = ""; + /// 지금 타이머 라벨에 들어 있는 색(실측 덤프용 · D-23 검증). + public static Color TimerColor { get { return s_timer != null ? s_timer.color : new Color(0f, 0f, 0f, 0f); } } + /// 지금 경고색으로 보이는가(실측 덤프용). + public static bool TimerWarn { get { return s_shownWarn; } } public static bool Subscribed { get { return s_subscribed; } } public static bool Bound { get { return s_root != null; } } public static bool Visible { get { return s_visible; } } @@ -123,6 +132,8 @@ namespace WL.UI s_bossPhase = false; s_shownSec = int.MinValue; s_shownWarn = false; s_shownPhaseKind = -1; s_shownPhaseZone = -1; + var st0 = St; + if (st0 == null || st0.timerResetColorOnRunStart) s_timerColorDirty = true; // WL-813q2(D-23) BuildZoneCells(e.zoneCount); ResetZoneCells(); SetVisible(true); @@ -208,9 +219,18 @@ namespace WL.UI if (s_timer == null || s == null || !s.timerEnabled) return; int t = remaining <= 0f ? 0 : Mathf.CeilToInt(remaining); bool warn = s.timerWarnSeconds > 0f && remaining <= s.timerWarnSeconds; + // WL-813q2(D-23) — 런 시작 직후 1회는 값이 같아도 색을 반드시 다시 쓴다(플래그는 여기서 바로 꺼진다 + // → 같은 초 100틱에서 색 쓰기 0회 = GC 0 규약 유지). + if (s_timerColorDirty) + { + s_timerColorDirty = false; + s_shownWarn = warn; + s_timer.color = warn ? s.timerWarnColor : s.timerColor; + TimerColorWrites++; + } if (t == s_shownSec && warn == s_shownWarn) return; // 같은 초 = 아무 것도 하지 않는다(GC 0) s_shownSec = t; - if (warn != s_shownWarn) { s_shownWarn = warn; s_timer.color = warn ? s.timerWarnColor : s.timerColor; } + if (warn != s_shownWarn) { s_shownWarn = warn; s_timer.color = warn ? s.timerWarnColor : s.timerColor; TimerColorWrites++; } int n = FormatMMSS(s_timeBuf, remaining); s_timer.SetText(s_timeBuf, 0, n); TimerWrites++; @@ -477,8 +497,8 @@ namespace WL.UI public static string ResetState() { StartedSeen = TickSeen = ZoneClearedSeen = GateSeen = BossSeen = EndedSeen = 0; - TimerWrites = PhaseWrites = FillWrites = 0; - s_shownSec = int.MinValue; s_shownWarn = false; + TimerWrites = PhaseWrites = FillWrites = TimerColorWrites = 0; + s_shownSec = int.MinValue; s_shownWarn = false; s_timerColorDirty = false; s_shownPhaseKind = -1; s_shownPhaseZone = -1; s_bossPhase = false; s_remaining = 0f; ResetZoneCells(); @@ -516,6 +536,10 @@ namespace WL.UI " gate=" + GateSeen + " boss=" + BossSeen + " ended=" + EndedSeen); sb.AppendLine(" 타이머 \"" + TimerText + "\" (남은 " + s_remaining.ToString("F1") + "s · 경고=" + s_shownWarn + " · 쓰기 " + TimerWrites + "회) · 단계 \"" + PhaseText + "\" (쓰기 " + PhaseWrites + "회)"); + sb.AppendLine(" [813q2] 타이머 색 = " + ColorUtility.ToHtmlStringRGB(TimerColor) + + " (색 쓰기 " + TimerColorWrites + "회 · 다음런리셋대기=" + s_timerColorDirty + + " · 기본 " + (s != null ? ColorUtility.ToHtmlStringRGB(s.timerColor) : "-") + + " / 경고 " + (s != null ? ColorUtility.ToHtmlStringRGB(s.timerWarnColor) : "-") + ")"); sb.Append(" 존 칸 " + s_zoneCells + " (fill 쓰기 " + FillWrites + "회):"); for (int i = 0; i < s_zoneCells; i++) sb.Append(" [").Append(i).Append("]=").Append(ZoneFill(i).ToString("F2")) @@ -601,6 +625,18 @@ namespace WL.UI } public static void RaiseRunEnded(in RunResult r) { RunEvents.RunEnded.Dispatch(in r); } + + /// WL-813q2 — 엘리트 집계 검증용 처치 이벤트(victim/killer 는 null · 같은 Dispatch 경로). + public static void RaiseKilled(eSubRol subRole) + { + var e = new WL.Combat.Core.KilledEvent + { + victim = null, killer = null, subRole = subRole, id = 0, + position = Vector3.zero, byDirectHit = true, + time = Time.unscaledTime, frame = Time.frameCount, + }; + WL.Combat.Core.CombatEvents.Killed.Dispatch(in e); + } } /// 런 UI(HUD · 결과 화면)를 매 프레임 돌리는 런타임 전용 러너(씬에 굽지 않는다). diff --git a/Assets/WL/UI/Scripts/RunResultUI.cs b/Assets/WL/UI/Scripts/RunResultUI.cs index be8d655c9..b425d9f9d 100644 --- a/Assets/WL/UI/Scripts/RunResultUI.cs +++ b/Assets/WL/UI/Scripts/RunResultUI.cs @@ -22,6 +22,19 @@ // 결과 화면은 미뤘다가(`resultDeferWhileRevive`) 부활 팝업이 닫힌 뒤에 뜬다. // `TimeScaleArbiter` 는 **건드리지 않는다**(발주서 §1-2). // +// ■ WL-813q2(D-18) — 위 순서에 구멍이 있었다. +// Q5 캡처 `PD2_07_result_revive_overlap.png` 실측: 제목이 **"시간 종료"** = 결과 화면이 **먼저** 떴고, +// 그 뒤에 죽어서 부활 팝업(813z 전용 Canvas `sortingOrder=60`)이 **위에** 겹쳤다 → 9행 중 4행이 가려진다. +// `RunEnded` 시점에는 아직 아무도 죽지 않았으니 `ReviveDialog.Busy` 가 false 라 미룸이 걸릴 수 없다. +// 3층으로 막는다(전부 SO · 각각 끄면 813q 동작): +// ① `resultDeferWhileDying` — 미룸 판정을 813i `DeathFlow.State`(Dying/AwaitingRevive)로 바꾼다 +// (`ReviveDialog.Busy` 는 보조 · 813z 워치독 부활 경로처럼 팝업이 안 잡히는 경우까지 덮는다). +// ② `skipRevivePopupAfterRunEnd` — 런이 끝난 뒤(`RunDirector.IsRunning=false`) 사망은 런 밖이므로 +// `DeathFlow.Revive()`(813i 공개 API)를 바로 불러 **팝업 자체를 생략**한다. = 겹칠 일이 없다. +// ③ `resultLowerWhileRevive` — 그래도 겹치면 결과 화면을 **부활 뒤로 재정렬** +// (전용 Canvas `sortingOrder` 40 → 20 · `SetAsFirstSibling`), 부활이 끝나면 되돌린다. +// 🔴 Gameplay 는 **읽기·구독·공개 API 호출만** 한다(파일 수정 0). +// // ■ 프리팹 diff 0 — 노드는 전부 런타임 생성(`HideFlags.DontSave`) · 1회 만들고 재사용. // ■ C8 롤백 — `WLRunUiSettings.enabled_ = 0`(또는 `resultEnabled = 0`) → 구독 0 · 노드 0 · 표시 0. // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). @@ -31,7 +44,9 @@ using System.Text; using TMPro; using UnityEngine; using UnityEngine.UI; +using WL.Combat.Core; using WL.Combat.Run; +using WL.Combat.Survival; namespace WL.UI { @@ -54,6 +69,8 @@ namespace WL.UI private static TextMeshProUGUI s_nextLabel, s_closeLabel; private static Image s_nextBg, s_closeBg; private static TMP_FontAsset s_font; + private static Canvas s_canvas; // WL-813q2 — 전용 Canvas(부활 팝업 60 아래) + private static GraphicRaycaster s_raycaster; // ── 복사 버퍼 (🔴 813p 배열 재사용 계약) ────────────────────────────── private static RunResult s_result; @@ -66,6 +83,9 @@ namespace WL.UI private static bool s_subscribed; private static bool s_open; private static bool s_pending; // 부활 팝업 때문에 미뤄 둔 결과가 있는가 + private static bool s_lowered; // WL-813q2 — 부활 뒤로 재정렬해 둔 상태인가 + private static int s_eliteKills; // WL-813q2 — 런 중 엘리트 처치(UI 자체 집계) + private static int s_eliteKillsAtEnd; // RunEnded 순간의 스냅샷(결과 화면이 쓰는 값) private static readonly StringBuilder s_sb = new StringBuilder(128); private static readonly char[] s_timeBuf = new char[8]; @@ -74,10 +94,21 @@ namespace WL.UI public static int BufferGrowCount; // 복사 버퍼를 새로 잡은 횟수(2번째 런부터 0 = GC 0) public static bool UsedCommonPopup; public static string LastShowLog = ""; + // ── WL-813q2(D-18) 진단 ────────────────────────────────────────────── + public static int DiedSeen, ReviveRequestSeen, RevivedSeen; + public static int SkipPopupReviveCount; // 런 밖 사망 → 팝업 생략 즉시 부활을 부른 횟수 + public static int LowerCount, RaiseCount; // 결과 화면을 부활 뒤로 내린/되돌린 횟수 + public static int EliteKilledSeen; // 런 중 본 엘리트 처치(UI 자체 집계) + public static string LastDeathLog = ""; public static bool Subscribed { get { return s_subscribed; } } public static bool Bound { get { return s_root != null; } } public static bool IsOpen { get { return s_open; } } public static bool Pending { get { return s_pending; } } + public static bool Lowered { get { return s_lowered; } } + public static int SortingOrder { get { return s_canvas != null && s_canvas.overrideSorting ? s_canvas.sortingOrder : -1; } } + public static int SiblingIndex { get { return s_root != null ? s_root.GetSiblingIndex() : -1; } } + public static int EliteKills { get { return s_eliteKills; } } + public static int EliteKillsAtEnd { get { return s_eliteKillsAtEnd; } } public static int VisibleRows { get; private set; } /// 복사해 둔 결과(배열 3종은 **자기 버퍼**를 가리킨다). public static RunResult Copied { get { return s_result; } } @@ -101,22 +132,95 @@ namespace WL.UI public static string Subscribe(bool on) { if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; - if (on) { RunEvents.RunEnded.Add(OnRunEnded); RunEvents.RunStarted.Add(OnRunStarted); } - else { RunEvents.RunEnded.Remove(OnRunEnded); RunEvents.RunStarted.Remove(OnRunStarted); } + if (on) + { + RunEvents.RunEnded.Add(OnRunEnded); RunEvents.RunStarted.Add(OnRunStarted); + // WL-813q2 — 813i DeathFlow 는 **읽기·구독만**(Gameplay 파일 수정 0) + DeathFlow.Died.Add(OnDied); + DeathFlow.ReviveRequested.Add(OnReviveRequested); + DeathFlow.Revived.Add(OnRevived); + CombatEvents.Killed.Add(OnKilled); // 엘리트 집계(RunResult 에 없다) + } + else + { + RunEvents.RunEnded.Remove(OnRunEnded); RunEvents.RunStarted.Remove(OnRunStarted); + DeathFlow.Died.Remove(OnDied); + DeathFlow.ReviveRequested.Remove(OnReviveRequested); + DeathFlow.Revived.Remove(OnRevived); + CombatEvents.Killed.Remove(OnKilled); + } s_subscribed = on; - return "구독=" + on + " (RunEnded/RunStarted)"; + return "구독=" + on + " (RunEnded/RunStarted + DeathFlow 3종 + Killed)"; } // ── 이벤트 ──────────────────────────────────────────────────────────── private static void OnRunStarted(in RunStartedEvent e) { s_pending = false; + s_eliteKills = 0; // WL-813q2 — 런마다 엘리트 집계 리셋 if (s_open) Hide(); // 다음 런이 시작되면 결과 화면은 닫힌다 + RaiseToFront(); // 다음 런에서 재정렬이 남지 않게 되돌린다 + } + + /// 엘리트 처치 집계(🔴 813p RunResult 에 엘리트 필드가 없다 · 문자열 0 = GC 0). + private static void OnKilled(in KilledEvent e) + { + if (e.subRole != eSubRol.Elite) return; + EliteKilledSeen++; + if (!RunDirector.IsRunning) return; // 런 밖 처치는 이번 런 값이 아니다 + s_eliteKills++; + } + + // ── WL-813q2(D-18) 사망/부활 ────────────────────────────────────────── + /// 사망 시작(813i `DeathFlow.Died`) — 런 밖이면 팝업을 생략하고 즉시 부활시킨다. + private static void OnDied(in DeathEvent e) + { + DiedSeen++; + HandleDeathStart("Died"); + } + + /// 부활 팝업 요청(813i `DeathFlow.ReviveRequested`) — Died 를 놓친 경우의 2차 방어. + private static void OnReviveRequested(in ReviveRequestEvent e) + { + ReviveRequestSeen++; + HandleDeathStart("ReviveRequested"); + } + + /// 런 밖 사망이면 즉시 부활 · 아니면 이미 떠 있는 결과 화면을 부활 뒤로 재정렬. + private static string HandleDeathStart(string from) + { + var s = St; + if (s == null || !WLRunUiSettings.Enabled || !s.resultEnabled) return "off"; + + // ② 런이 **끝난 뒤**(RunPhase.Ended = EndRun ~ 다음 StartRun 사이)의 사망은 런 밖이다 — + // 부활을 물을 이유가 없으니 팝업을 생략하고 바로 살린다. + // 🔴 Idle(런을 아직 시작도 안 한 로비 등)은 건드리지 않는다 = 813i 원래 흐름 그대로. + if (s.skipRevivePopupAfterRunEnd && RunDirector.Phase == RunPhase.Ended) + { + bool ok = DeathFlow.Revive(); // 813i 공개 API(Dying = 연출 끝에 · AwaitingRevive = 즉시) + if (ok) SkipPopupReviveCount++; + LastDeathLog = from + " — 런 밖 사망 → 팝업 생략 즉시 부활(" + ok + ")"; + if (s.verboseLog) Debug.Log("[RunResultUI] " + LastDeathLog); + return LastDeathLog; + } + + // ③ 그래도 겹치면(런 중 사망 + 결과 화면이 이미 떠 있음) 결과를 부활 뒤로 내린다. + if (s_open && s.resultLowerWhileRevive) LowerBehindRevive(); + LastDeathLog = from + " — 런 중 사망(재정렬=" + s_lowered + ")"; + return LastDeathLog; + } + + /// 부활 완료(813i `DeathFlow.Revived` · 워치독 자동 부활 포함) — 결과를 다시 앞으로. + private static void OnRevived(in RevivedEvent e) + { + RevivedSeen++; + RaiseToFront(); // 미뤄 둔 결과는 러너 Tick 이 곧 띄운다 } private static void OnRunEnded(in RunResult e) { EndedSeen++; + s_eliteKillsAtEnd = s_eliteKills; // WL-813q2 — 결과 화면이 쓰는 값은 이 순간의 스냅샷 CopyResult(in e); // 🔴 배열 3종을 즉시 자기 버퍼로 복사 var s = St; if (s == null || !WLRunUiSettings.Enabled || !s.resultEnabled) return; @@ -175,22 +279,98 @@ namespace WL.UI } // ── 표시 ────────────────────────────────────────────────────────────── + /// + /// WL-813q2(D-18) — 「부활 우선」 판정. **주 판정 = 813i `DeathFlow.State`**(Dying/AwaitingRevive), + /// `ReviveDialog.Busy`(공용 팝업 경로 · 813q)는 보조. 둘 다 SO 로 끌 수 있다. + /// + public static bool ReviveBusy + { + get + { + var s = St; + if (s == null) return false; + if (s.resultDeferWhileDying && DeathFlow.State != DeathState.None) return true; + if (s.resultDeferWhileRevive && ReviveDialog.Busy) return true; + return false; + } + } + + /// 「부활 우선」이 걸린 이유(실측 덤프용 · 표시 경로가 아니다). + public static string ReviveBusyReason + { + get + { + var s = St; + if (s == null) return "설정 없음"; + if (s.resultDeferWhileDying && DeathFlow.State != DeathState.None) return "DeathFlow." + DeathFlow.State; + if (s.resultDeferWhileRevive && ReviveDialog.Busy) return "ReviveDialog.Busy"; + return "없음"; + } + } + /// 부활 팝업이 비면 띄운다. 아직 떠 있으면 미뤄 둔다(러너가 다시 부른다). public static string TryShow(float now) { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음"; if (!s_pending) return "대기 중인 결과 없음"; - if (s.resultDeferWhileRevive && ReviveDialog.Busy) + if (ReviveBusy) { DeferredCount++; - LastShowLog = "부활 팝업 우선 — 결과 화면 대기"; + LastShowLog = "부활 우선 — 결과 화면 대기(" + ReviveBusyReason + ")"; return LastShowLog; } s_pending = false; return Show(); } + // ── WL-813q2(D-18) 재정렬 — 813z 부활 팝업(sortingOrder 60)보다 아래로 ─── + /// 결과 화면을 부활 팝업 뒤로 내린다(sortingOrder 낮춤 + 형제 맨 앞). + public static string LowerBehindRevive() + { + var s = St; + if (s == null || s_root == null) return "노드 없음"; + if (s_lowered) return "이미 재정렬됨"; + s_lowered = true; + LowerCount++; + EnsureCanvas(s); + if (s_canvas != null) s_canvas.sortingOrder = s.resultSortingOrderWhileRevive; + s_root.SetAsFirstSibling(); + return "부활 뒤로 재정렬 sortingOrder=" + SortingOrder + " sibling=" + SiblingIndex; + } + + /// 재정렬을 되돌린다(부활 완료·다음 런). + public static string RaiseToFront() + { + var s = St; + if (s == null || s_root == null) { s_lowered = false; return "노드 없음"; } + if (!s_lowered) return "재정렬 없음"; + s_lowered = false; + if (!s.resultRaiseAfterRevive) return "resultRaiseAfterRevive=0 — 되돌리지 않음"; + RaiseCount++; + EnsureCanvas(s); + if (s_canvas != null) s_canvas.sortingOrder = s.resultSortingOrder; + s_root.SetAsLastSibling(); + return "앞으로 복귀 sortingOrder=" + SortingOrder + " sibling=" + SiblingIndex; + } + + /// 전용 Canvas(overrideSorting) 보장 — 813z 부활 팝업과 같은 방식. + private static void EnsureCanvas(WLRunUiSettings s) + { + if (s == null || s_root == null || !s.resultOwnCanvas) return; + if (s_canvas == null) + { + s_canvas = s_root.GetComponent(); + if (s_canvas == null) s_canvas = s_root.gameObject.AddComponent(); + } + if (!s_canvas.overrideSorting) s_canvas.overrideSorting = true; + if (s_raycaster == null) + { + s_raycaster = s_root.GetComponent(); + if (s_raycaster == null) s_raycaster = s_root.gameObject.AddComponent(); + } + } + /// 지금 바로 결과 화면을 띄운다(공용 팝업 스위치 → 런타임 패널). public static string Show() { @@ -213,7 +393,11 @@ namespace WL.UI if (s_group != null) { s_group.alpha = 1f; s_group.blocksRaycasts = true; s_group.interactable = true; } if (s_root.gameObject.activeSelf == false) s_root.gameObject.SetActive(true); s_root.SetAsLastSibling(); // 형제 순서 = 그리는 순서(813tj 교훈) — HUD 위로 - LastShowLog = "런타임 패널 표시 outcome=" + s_result.outcome + " 행=" + VisibleRows; + s_lowered = false; + EnsureCanvas(s); // WL-813q2 — 부활 팝업(60)보다 아래 고정 + if (s_canvas != null) s_canvas.sortingOrder = s.resultSortingOrder; + LastShowLog = "런타임 패널 표시 outcome=" + s_result.outcome + " 행=" + VisibleRows + + " sortingOrder=" + SortingOrder; if (s.verboseLog) Debug.Log("[RunResultUI] " + LastShowLog); return LastShowLog; } @@ -352,6 +536,7 @@ namespace WL.UI case WLRunResultRow.Level: return s.rowLevelLabel; case WLRunResultRow.LootByGrade: return s.rowLootLabel; case WLRunResultRow.Gold: return s.rowGoldLabel; + case WLRunResultRow.EliteKills: return s.rowEliteKillsLabel; default: return s.rowDeathsLabel; } } @@ -364,7 +549,7 @@ namespace WL.UI AppendTime(sb, s_result.totalSec, s); break; case WLRunResultRow.BossClear: - AppendTime(sb, s_result.bossClearSec, s); + AppendBossClear(sb, s); // WL-813q2 — 미처치면 "미처치 (HP 남음 N %)" break; case WLRunResultRow.Kills: AppendFormat2(sb, s.rowKillsFormat, s_result.kills, s_result.bossKills); @@ -387,12 +572,37 @@ namespace WL.UI case WLRunResultRow.Gold: sb.Append(s_result.gold); break; + case WLRunResultRow.EliteKills: + sb.Append(s.resultCountElitesInUi ? s_eliteKillsAtEnd : 0); + break; default: AppendFormat2(sb, s.rowDeathsFormat, s_result.deaths, s_result.potionsUsed); break; } } + /// + /// WL-813q2 §1-3 — 보스 처치 행. 처치했으면 mm:ss, 아니면 "미처치". + /// 🔴 813p `RunResult` 에는 **보스 HP 필드가 없다**(구조체 실측) → 남은 HP 는 813c `BossHpBar` 가 + /// 이번 런에 실제로 물고 있던 마지막 비율(UI 자체 실측)을 쓴다(`resultBossHpFromUi`). + /// 바가 보스를 한 번도 물지 못했으면 퍼센트 없이 "미처치"만 쓴다. + /// + private static void AppendBossClear(StringBuilder sb, WLRunUiSettings s) + { + if (s_result.bossClearSec >= 0f) { AppendTime(sb, s_result.bossClearSec, s); return; } + + float ratio = s.resultBossHpFromUi ? BossHpBar.RunBossHpRatio : -1f; + if (ratio >= 0f && !string.IsNullOrEmpty(s.resultBossNotClearedHpFormat)) + { + // 0 % 는 "죽었는데 처치 기록이 없는" 경우라 올림하지 않는다(살아 있으면 최소 1 %). + int pct = (int)System.Math.Round(Mathf.Clamp01(ratio) * 100f, System.MidpointRounding.AwayFromZero); + if (pct <= 0 && ratio > 0f) pct = 1; + AppendFormat2(sb, s.resultBossNotClearedHpFormat, pct, 0); + return; + } + sb.Append(string.IsNullOrEmpty(s.resultBossNotClearedText) ? s.resultDashText : s.resultBossNotClearedText); + } + /// 초 → mm:ss (음수 = 미발생 → 대시 문구). private static void AppendTime(StringBuilder sb, float sec, WLRunUiSettings s) { @@ -475,9 +685,12 @@ namespace WL.UI s_closeButton = NewButton(s_box, "btn_close", out s_closeBg, out s_closeLabel, OnCloseListener); } EnsureRows(s); + EnsureCanvas(s); // WL-813q2 — 전용 Canvas(overrideSorting) + if (s_canvas != null) s_canvas.sortingOrder = s.resultSortingOrder; + s_lowered = false; string layout = ApplyLayout(); Hide(); - return "연결 " + s.resultParentPath + " · " + layout; + return "연결 " + s.resultParentPath + " · " + layout + " · sortingOrder=" + SortingOrder; } private static Button NewButton(RectTransform parent, string name, out Image bg, out TextMeshProUGUI label, @@ -636,6 +849,10 @@ namespace WL.UI { EndedSeen = ShownCount = DeferredCount = NextRunCount = CloseCount = SkipAbandonCount = 0; BufferGrowCount = 0; + DiedSeen = ReviveRequestSeen = RevivedSeen = SkipPopupReviveCount = 0; + LowerCount = RaiseCount = EliteKilledSeen = 0; + s_eliteKills = s_eliteKillsAtEnd = 0; + s_lowered = false; LastDeathLog = ""; s_pending = false; UsedCommonPopup = false; LastShowLog = ""; Hide(); return "상태 초기화"; @@ -649,7 +866,7 @@ namespace WL.UI if (Application.isPlaying) Object.Destroy(s_root.gameObject); else Object.DestroyImmediate(s_root.gameObject); } - s_root = null; s_group = null; s_dim = null; s_box = null; s_boxBg = null; + s_root = null; s_canvas = null; s_raycaster = null; s_group = null; s_dim = null; s_box = null; s_boxBg = null; s_title = null; s_rowRoot = null; s_nextButton = null; s_closeButton = null; s_nextLabel = null; s_closeLabel = null; s_nextBg = null; s_closeBg = null; s_row = new RectTransform[0]; s_rowLabel = new TextMeshProUGUI[0]; s_rowValue = new TextMeshProUGUI[0]; @@ -668,6 +885,15 @@ namespace WL.UI " abandon생략=" + SkipAbandonCount + " 버퍼증설=" + BufferGrowCount + " 공용팝업=" + UsedCommonPopup); sb.AppendLine(" 제목 \"" + TitleText + "\" · 행 " + VisibleRows + "개 · 부활팝업Busy=" + ReviveDialog.Busy); + sb.AppendLine(" [813q2] 부활우선=" + ReviveBusy + "(" + ReviveBusyReason + ") · DeathFlow=" + DeathFlow.State + + " 런=" + RunDirector.Phase + "(running=" + RunDirector.IsRunning + ") · died=" + DiedSeen + " request=" + ReviveRequestSeen + + " revived=" + RevivedSeen + " 팝업생략부활=" + SkipPopupReviveCount + + " · 재정렬=" + s_lowered + "(내림 " + LowerCount + "/복귀 " + RaiseCount + ")" + + " sortingOrder=" + SortingOrder + " sibling=" + SiblingIndex); + sb.AppendLine(" [813q2] 엘리트 런중=" + s_eliteKills + " 종료스냅=" + s_eliteKillsAtEnd + + " 전체본것=" + EliteKilledSeen + " · 보스HP(UI 실측)=" + + (BossHpBar.RunBossHpRatio >= 0f ? (BossHpBar.RunBossHpRatio * 100f).ToString("F1") + "%" : "없음") + + " · 마지막 사망 로그 = " + LastDeathLog); if (s != null && s.resultRows != null) for (int i = 0; i < s.resultRows.Length; i++) { @@ -691,7 +917,11 @@ namespace WL.UI sb.AppendLine(" 설정 enabled=" + s.enabled_ + " result=" + s.resultEnabled + " 공용팝업=" + s.resultUseCommonPopup + " abandon표시=" + s.resultShowOnAbandon + " 부활우선=" + s.resultDeferWhileRevive + " box=" + s.resultBoxSizePx + " 행수=" + (s.resultRows != null ? s.resultRows.Length : 0) + - " 합산피해=" + s.resultMaxDamageUseBurst); + " 합산피해=" + s.resultMaxDamageUseBurst + + " · [813q2] 사망중미룸=" + s.resultDeferWhileDying + " 런밖즉시부활=" + s.skipRevivePopupAfterRunEnd + + " 전용캔버스=" + s.resultOwnCanvas + "(" + s.resultSortingOrder + "→" + s.resultSortingOrderWhileRevive + ")" + + " 재정렬=" + s.resultLowerWhileRevive + "/" + s.resultRaiseAfterRevive + + " 엘리트UI집계=" + s.resultCountElitesInUi + " 보스HP UI=" + s.resultBossHpFromUi); return sb.ToString(); } } diff --git a/Assets/WL/UI/Settings/Resources/WL/WLRunUiSettings.asset b/Assets/WL/UI/Settings/Resources/WL/WLRunUiSettings.asset index 348decf50..ce89c953d 100644 --- a/Assets/WL/UI/Settings/Resources/WL/WLRunUiSettings.asset +++ b/Assets/WL/UI/Settings/Resources/WL/WLRunUiSettings.asset @@ -62,6 +62,13 @@ MonoBehaviour: resultUseCommonPopup: 0 resultShowOnAbandon: 0 resultDeferWhileRevive: 1 + resultDeferWhileDying: 1 + skipRevivePopupAfterRunEnd: 1 + resultOwnCanvas: 1 + resultSortingOrder: 40 + resultSortingOrderWhileRevive: 20 + resultLowerWhileRevive: 1 + resultRaiseAfterRevive: 1 resultWinTitle: "\uB7F0 \uD074\uB9AC\uC5B4!" resultTimeoutTitle: "\uC2DC\uAC04 \uC885\uB8CC" resultAbandonTitle: "\uB7F0 \uC911\uB2E8" @@ -91,6 +98,7 @@ MonoBehaviour: - 0 - 1 - 2 + - 9 - 3 - 4 - 5 @@ -106,6 +114,7 @@ MonoBehaviour: rowLootLabel: "\uD68D\uB4DD" rowGoldLabel: "\uACE8\uB4DC" rowDeathsLabel: "\uC0AC\uB9DD / \uBB3C\uC57D" + rowEliteKillsLabel: "\uC5D8\uB9AC\uD2B8 \uCC98\uCE58" rowKillsFormat: "{0} (\uBCF4\uC2A4 {1})" rowLevelFormat: "{0} \u2192 {1}" rowDeathsFormat: '{0} / {1}' @@ -114,6 +123,10 @@ MonoBehaviour: rowLootEmptyText: '0' resultMaxDamageUseBurst: 1 resultHideLevelWhenZero: 1 + resultBossNotClearedText: "미처치" + resultBossNotClearedHpFormat: "미처치 (HP 남음 {0} %)" + resultBossHpFromUi: 1 + resultCountElitesInUi: 1 resultNextRunText: "\uB2E4\uC74C \uB7F0" resultCloseText: "\uB2EB\uAE30" resultButtonSizePx: {x: 340, y: 104} @@ -124,3 +137,7 @@ MonoBehaviour: resultNextLabelColor: {r: 0.04, g: 0.09, b: 0.06, a: 1} resultCloseBgColor: {r: 0.16, g: 0.18, b: 0.22, a: 1} resultCloseLabelColor: {r: 0.9, g: 0.92, b: 0.95, a: 1} + timerResetColorOnRunStart: 1 + bossResetOnRunStart: 1 + bossBarRebindOnRunStart: 1 + bossBannerRetriggerOnRunStart: 0 diff --git a/Assets/WL/UI/Settings/WLRunUiSettings.cs b/Assets/WL/UI/Settings/WLRunUiSettings.cs index faddaad49..f1e3b607a 100644 --- a/Assets/WL/UI/Settings/WLRunUiSettings.cs +++ b/Assets/WL/UI/Settings/WLRunUiSettings.cs @@ -46,6 +46,8 @@ namespace WL.UI Gold = 7, /// 사망 / 물약. DeathsPotions = 8, + /// 엘리트 처치 수(WL-813q2 · 813p RunResult 에 없어 UI 가 직접 센다). + EliteKills = 9, } [CreateAssetMenu(fileName = "WLRunUiSettings", menuName = "WL/Run UI Settings", order = 33)] @@ -226,6 +228,34 @@ namespace WL.UI [Tooltip("부활 팝업(813i/813tj)이 떠 있거나 예약 중이면 결과 화면을 미뤘다가 닫힌 뒤 띄운다.")] public bool resultDeferWhileRevive = true; + // ── D-18 (WL-813q2) ────────────────────────────────────────────────── + // Q5 캡처 `PD2_07` 실측: 결과 화면("시간 종료")이 **먼저** 뜬 뒤 사망 → 부활 팝업이 그 위에 겹쳐 + // 9행 중 4행을 가린다. 813q 는 `ReviveDialog.Busy` 만 봤는데 RunEnded 시점에는 아직 아무도 + // 죽지 않아 Busy=false 였다 → 미룸이 걸리지 않는다. 아래 3개가 그 구멍을 막는다. + [Header("결과 화면 — 부활 겹침 (WL-813q2 · D-18)")] + [Tooltip("813i DeathFlow 상태(Dying/AwaitingRevive)를 **주 판정**으로 쓴다. " + + "ReviveDialog.Busy(공용 팝업 경로)는 보조로 남는다. 끄면 813q 동작(Busy 만).")] + public bool resultDeferWhileDying = true; + + [Tooltip("런이 끝난 뒤(RunDirector.Phase == Ended · EndRun ~ 다음 StartRun 사이) 죽으면 부활 팝업을 " + + "생략하고 즉시 부활한다(813i DeathFlow.Revive() 공개 API). Idle(로비)은 건드리지 않는다.")] + public bool skipRevivePopupAfterRunEnd = true; + + [Tooltip("결과 화면에 전용 Canvas(overrideSorting)를 붙인다. 끄면 형제 순서만으로 겹친다(813q 동작).")] + public bool resultOwnCanvas = true; + + [Tooltip("결과 화면 평상시 sortingOrder. 813z 부활 팝업(reviveSortingOrder=60)보다 **아래**여야 한다.")] + public int resultSortingOrder = 40; + + [Tooltip("부활 진행 중 결과 화면 sortingOrder(더 아래로 재정렬).")] + public int resultSortingOrderWhileRevive = 20; + + [Tooltip("결과 화면이 이미 떠 있는데 사망하면 부활 뒤로 재정렬한다(sortingOrder 낮춤 + SetAsFirstSibling).")] + public bool resultLowerWhileRevive = true; + + [Tooltip("부활이 끝나면 결과 화면을 다시 앞으로 되돌린다(sortingOrder 복귀 + SetAsLastSibling).")] + public bool resultRaiseAfterRevive = true; + [Tooltip("승리 제목.")] public string resultWinTitle = "런 클리어!"; @@ -315,6 +345,7 @@ namespace WL.UI public string rowLootLabel = "획득"; public string rowGoldLabel = "골드"; public string rowDeathsLabel = "사망 / 물약"; + public string rowEliteKillsLabel = "엘리트 처치"; [Tooltip("처치 수 값 문구 — {0} = 전체 처치, {1} = 그중 보스.")] public string rowKillsFormat = "{0} (보스 {1})"; @@ -340,6 +371,22 @@ namespace WL.UI [Tooltip("레벨 행은 levelFrom·levelTo 가 둘 다 0 이면(에디트 모드 등) 감춘다.")] public bool resultHideLevelWhenZero = true; + // ── 행 보강 (WL-813q2 §1-3) ─────────────────────────────────────────── + [Header("결과 화면 — 행 보강 (WL-813q2)")] + [Tooltip("보스 미처치 행 문구(HP 를 모를 때). 비우면 resultDashText(—)를 그대로 쓴다.")] + public string resultBossNotClearedText = "미처치"; + + [Tooltip("보스 미처치 + 남은 HP 를 아는 경우의 문구 — {0} = 남은 HP 퍼센트(정수).")] + public string resultBossNotClearedHpFormat = "미처치 (HP 남음 {0} %)"; + + [Tooltip("🔴 813p RunResult 에는 보스 HP 필드가 없다(실측). 켜면 813c 보스 HP 바가 이번 런에 " + + "실제로 물었던 마지막 비율을 쓴다(UI 자체 실측). 끄면 남은 HP 를 쓰지 않는다.")] + public bool resultBossHpFromUi = true; + + [Tooltip("🔴 813p RunResult 에는 엘리트 집계가 없다(실측). 켜면 UI 가 CombatEvents.Killed 의 " + + "subRole==Elite 를 런 중에 직접 센다. 끄면 엘리트 행은 0 으로 나온다.")] + public bool resultCountElitesInUi = true; + [Header("결과 화면 — 버튼")] [Tooltip("\"다음 런\" 버튼 문구 — 누르면 RunDirector.Restart() 하나만 부른다(813p 공개 API).")] public string resultNextRunText = "다음 런"; @@ -371,6 +418,23 @@ namespace WL.UI [Tooltip("\"닫기\" 버튼 글자 색.")] public Color resultCloseLabelColor = new Color(0.90f, 0.92f, 0.95f, 1f); + // ── 다음 런 리셋 (WL-813q2 · D-23) ──────────────────────────────────── + // Q5 `R3_30_after_next` 실측: 「다음 런」 뒤 이전 런의 보스 HP 바가 남고 타이머가 붉게 시작한다. + // 타이머는 RunStarted 에서 s_shownWarn 을 false 로 되돌리지만 **색을 다시 쓰지 않아서** + // (warn != s_shownWarn 이 false → 색 대입 생략) 이전 런의 경고색이 그대로 남는다. + [Header("다음 런 리셋 (WL-813q2 · D-23)")] + [Tooltip("RunStarted 마다 타이머 색을 기본색으로 되돌린다(경고색 잔존 버그).")] + public bool timerResetColorOnRunStart = true; + + [Tooltip("RunStarted 마다 보스 HP 바·등장 배너 상태를 리셋한다(이전 런의 바 잔존).")] + public bool bossResetOnRunStart = true; + + [Tooltip("리셋 뒤 살아 있는 보스가 있으면 보스 HP 바를 다시 물린다(813y ⑤ 늦은 구독 경로 재사용).")] + public bool bossBarRebindOnRunStart = true; + + [Tooltip("리셋 뒤 살아 있는 보스가 있으면 등장 배너도 다시 띄운다(기본 끔 — 새 런 시작에 배너는 요란하다).")] + public bool bossBannerRetriggerOnRunStart = false; + // ── 파생 헬퍼 (코드 상수 0) ─────────────────────────────────────────── /// px → 캔버스 유닛 계수. 813c 의 산식 하나만 쓴다(중복 0). public float UnitsPerPx(Canvas canvas)