// ───────────────────────────────────────────────────────────────────────────── // RunResultUI.cs — 런 결과 화면(승리 / 시간 종료 · 집계 · 등급별 획득 · "다음 런") // (WL-813q · #813) // // 기준서 §D-2 813q 행 · 발주서 WL-813q §1-2. // 구독 계약 = 813p 완료보고 §4 표 = `Assets/WL/Combat/Run/RunEvents.cs`(**읽기만** · 수정 0). // // 🔴 **배열 3종은 즉시 복사한다** — 813p 계약 §4 마지막 줄: // `RunResult.zoneClearSec` · `zoneKills` · `lootByGrade` 는 `RunDirector` 가 런 사이에 **재사용**한다. // 참조를 보관하면 다음 런에서 덮어써지므로 `RunEnded` 를 받는 즉시 자기 버퍼로 복사하고 // 구조체의 배열 필드를 그 버퍼로 갈아 끼운다(프로브가 「원본을 덮어써도 화면 값 불변」으로 증명). // 버퍼는 길이가 모자랄 때만 새로 잡는다 → 두 번째 런부터 할당 0. // // ■ 팝업 방식 = 813tj `ReviveDialog` 와 같은 구조(공용 팝업 시도 → 런타임 패널 폴백) // 실측: 공용 `Popup`(`Assets/Script/Info/Popup.cs` · `SortOrder_5`)은 본문 라벨(`label_msg`) **1개**와 // 프리팹에 고정된 버튼 문구만 준다 → "다음 런"·"닫기" 문구를 **프리팹 수정 없이** 넣을 수 없고 // 9행 + 등급 색 줄이 작은 메시지 상자에서 잘린다. 그래서 기본 경로는 **런타임 패널** // (= ReviveDialog 가 에디트 모드에서 실제로 타는 그 폴백 경로와 같은 조립 방식)이고, // 공용 팝업 경로는 SO 스위치 `resultUseCommonPopup` 로 남겨 둔다(기본 off). // // ■ 순서 — 813i/813tj 부활 팝업이 떠 있거나 예약 중이면 **부활 팝업이 우선**이다. // 결과 화면은 미뤘다가(`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 단일). // ───────────────────────────────────────────────────────────────────────────── 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 { /// 런 결과 화면(정적 · 노드는 런타임 생성 · 1회 만들고 재사용). public static class RunResultUI { // ── 노드 ────────────────────────────────────────────────────────────── private static Transform s_uiRoot; private static RectTransform s_root; private static CanvasGroup s_group; private static Image s_dim; private static RectTransform s_box; private static Image s_boxBg; private static TextMeshProUGUI s_title; private static RectTransform s_rowRoot; private static TextMeshProUGUI[] s_rowLabel = new TextMeshProUGUI[0]; private static TextMeshProUGUI[] s_rowValue = new TextMeshProUGUI[0]; private static RectTransform[] s_row = new RectTransform[0]; private static Button s_nextButton, s_closeButton; 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; private static float[] s_zoneClearSec = new float[0]; private static int[] s_zoneKills = new int[0]; private static int[] s_lootByGrade = new int[0]; private static int s_zoneLen, s_lootLen; // ── 상태 ────────────────────────────────────────────────────────────── 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]; // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── public static int EndedSeen, ShownCount, DeferredCount, NextRunCount, CloseCount, SkipAbandonCount; 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; } } public static float CopiedZoneClearSec(int i) { return i >= 0 && i < s_zoneLen ? s_zoneClearSec[i] : -1f; } public static int CopiedZoneKills(int i) { return i >= 0 && i < s_zoneLen ? s_zoneKills[i] : -1; } public static int CopiedLootByGrade(int g) { return g >= 0 && g < s_lootLen ? s_lootByGrade[g] : -1; } public static string TitleText { get { return s_title != null ? s_title.text : ""; } } private static WLRunUiSettings St { get { return WLRunUiSettings.Instance; } } // ── 부팅 ────────────────────────────────────────────────────────────── [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Boot() { var s = St; if (!WLRunUiSettings.Enabled || s == null || !s.resultEnabled) return; Subscribe(true); WLRunUiRunner.Ensure(); } public static string Subscribe(bool on) { if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; 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 + 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; if (e.outcome == RunOutcome.Abandon && !s.resultShowOnAbandon) { SkipAbandonCount++; s_pending = false; if (s.verboseLog) Debug.Log("[RunResultUI] 이탈(Abandon) — 팝업 없음(HUD 리셋만)"); return; // 발주서 §1-2: 이탈은 팝업 없음 } s_pending = true; TryShow(Time.unscaledTime); } /// 🔴 재사용 배열 3종을 자기 버퍼로 복사하고 구조체의 배열 필드를 갈아 끼운다. public static void CopyResult(in RunResult r) { s_result = r; // 스칼라 전부(배열 필드는 아직 원본 참조) int zn = r.zoneCount; if (r.zoneClearSec != null && r.zoneClearSec.Length < zn) zn = r.zoneClearSec.Length; if (r.zoneKills != null && r.zoneKills.Length < zn) zn = r.zoneKills.Length; if (zn < 0) zn = 0; s_zoneClearSec = EnsureFloat(s_zoneClearSec, zn); s_zoneKills = EnsureInt(s_zoneKills, zn); for (int i = 0; i < zn; i++) { s_zoneClearSec[i] = r.zoneClearSec != null ? r.zoneClearSec[i] : -1f; s_zoneKills[i] = r.zoneKills != null ? r.zoneKills[i] : 0; } s_zoneLen = zn; int ln = r.lootByGrade != null ? r.lootByGrade.Length : 0; s_lootByGrade = EnsureInt(s_lootByGrade, ln); for (int i = 0; i < ln; i++) s_lootByGrade[i] = r.lootByGrade[i]; s_lootLen = ln; // 원본 참조를 버리고 자기 버퍼를 가리키게 한다(다음 런에 덮어써져도 화면 값은 그대로). s_result.zoneClearSec = s_zoneClearSec; s_result.zoneKills = s_zoneKills; s_result.lootByGrade = s_lootByGrade; } private static float[] EnsureFloat(float[] buf, int n) { if (buf != null && buf.Length >= n) return buf; BufferGrowCount++; return new float[Mathf.Max(1, n)]; } private static int[] EnsureInt(int[] buf, int n) { if (buf != null && buf.Length >= n) return buf; BufferGrowCount++; return new int[Mathf.Max(1, n)]; } // ── 표시 ────────────────────────────────────────────────────────────── /// /// 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 (ReviveBusy) { DeferredCount++; 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() { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음"; ShownCount++; UsedCommonPopup = false; BuildBody(s); // 본문 문자열/행을 채운다(공용 팝업도 이 결과를 쓴다) if (s.resultUseCommonPopup) { string r = TryCommonPopup(s); if (UsedCommonPopup) { s_open = true; LastShowLog = r; return r; } } if (s_root == null) { LastShowLog = "노드 미조립 — Bind 먼저"; return LastShowLog; } ApplyLayout(); s_open = true; 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 위로 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; } /// 공용 Popup 싱글턴(SortOrder_5) 재사용 — ReviveDialog 와 같은 시도/폴백 구조. private static string TryCommonPopup(WLRunUiSettings s) { try { if (Popup.Ins == null) return "공용 Popup 없음 — 런타임 패널로"; Popup.Ins.Set(ePopupType.Two, 0, OnNextRunListener, OnCloseListener); if (Popup.Ins.label_msg != null) Popup.Ins.label_msg.text = BodyText; UsedCommonPopup = true; return "공용 Popup(SortOrder_5) 표시 — 버튼 문구는 프리팹 소유(미확인)"; } catch (System.Exception e) { UsedCommonPopup = false; return "공용 Popup 실패(" + e.GetType().Name + ") — 런타임 패널로"; } } public static void Hide() { s_open = false; if (s_group != null) { s_group.alpha = 0f; s_group.blocksRaycasts = false; s_group.interactable = false; } } /// [다음 런] — 813p 공개 API 하나만 부른다. public static string OnNextRun() { NextRunCount++; Hide(); try { RunDirector.Restart(); } catch (System.Exception e) { return "다음 런 — RunDirector.Restart() 예외(" + e.GetType().Name + ")"; } return "다음 런 — RunDirector.Restart() 호출"; } /// [닫기]. public static string OnClose() { CloseCount++; Hide(); return "닫기"; } private static void OnNextRunListener() { OnNextRun(); } private static void OnCloseListener() { OnClose(); } // ── 러너(매 프레임) ─────────────────────────────────────────────────── public static string Tick(float now) { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음"; if (!WLRunUiSettings.Enabled || !s.resultEnabled) { if (s_open) Hide(); return "off — 무동작"; } if (s_pending) return TryShow(now); return "tick ok"; } // ── 본문 ────────────────────────────────────────────────────────────── /// 마지막으로 만든 본문 전체 문자열(공용 팝업 · 실측 덤프용). public static string BodyText { get; private set; } = ""; /// 행 종류별 마지막 값 문자열(실측 덤프용). public static string RowValueText(WLRunResultRow row) { var s = St; if (s == null || s.resultRows == null) return ""; int slot = 0; for (int i = 0; i < s.resultRows.Length; i++) { if (!RowApplies(s, s.resultRows[i])) continue; if (s.resultRows[i] == row) return slot < s_rowValue.Length && s_rowValue[slot] != null ? s_rowValue[slot].text : ""; slot++; } return ""; } /// 그 행을 이번 결과에 실제로 그리는가(레벨 0 숨김 등). private static bool RowApplies(WLRunUiSettings s, WLRunResultRow row) { if (row == WLRunResultRow.Level && s.resultHideLevelWhenZero && s_result.levelFrom == 0 && s_result.levelTo == 0) return false; return true; } /// 행 라벨·값을 채운다(런 종료 1회 · 틱 경로가 아니다). public static string BuildBody(WLRunUiSettings s) { if (s == null) return "설정 없음"; EnsureRows(s); var body = new StringBuilder(256); body.Append(s.TitleFor(s_result.outcome)).Append('\n'); int slot = 0; var rows = s.resultRows != null ? s.resultRows : new WLRunResultRow[0]; for (int i = 0; i < rows.Length; i++) { var kind = rows[i]; if (!RowApplies(s, kind)) continue; string label = LabelOf(s, kind); s_sb.Length = 0; ValueOf(s, kind, s_sb); string value = s_sb.ToString(); // 노드가 아직 없어도(공용 팝업 경로 · Bind 전 프로브) 본문 문자열은 끝까지 만든다. if (slot < s_row.Length) { if (s_rowLabel[slot] != null) s_rowLabel[slot].SetText(label); if (s_rowValue[slot] != null) s_rowValue[slot].SetText(value); if (s_row[slot] != null && !s_row[slot].gameObject.activeSelf) s_row[slot].gameObject.SetActive(true); } body.Append(label).Append(" : ").Append(value).Append('\n'); slot++; } for (int i = slot; i < s_row.Length; i++) if (s_row[i] != null && s_row[i].gameObject.activeSelf) s_row[i].gameObject.SetActive(false); VisibleRows = slot; if (s_title != null) { s_title.SetText(s.TitleFor(s_result.outcome)); s_title.color = s.TitleColorFor(s_result.outcome); } BodyText = body.ToString(); return "행 " + slot + "개"; } private static string LabelOf(WLRunUiSettings s, WLRunResultRow row) { switch (row) { case WLRunResultRow.TotalTime: return s.rowTotalTimeLabel; case WLRunResultRow.BossClear: return s.rowBossClearLabel; case WLRunResultRow.Kills: return s.rowKillsLabel; case WLRunResultRow.MaxChain: return s.rowMaxChainLabel; case WLRunResultRow.MaxDamage: return s.rowMaxDamageLabel; 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; } } private static void ValueOf(WLRunUiSettings s, WLRunResultRow row, StringBuilder sb) { switch (row) { case WLRunResultRow.TotalTime: AppendTime(sb, s_result.totalSec, s); break; case WLRunResultRow.BossClear: AppendBossClear(sb, s); // WL-813q2 — 미처치면 "미처치 (HP 남음 N %)" break; case WLRunResultRow.Kills: AppendFormat2(sb, s.rowKillsFormat, s_result.kills, s_result.bossKills); break; case WLRunResultRow.MaxChain: sb.Append(s_result.maxChain); break; case WLRunResultRow.MaxDamage: // 🔴 Mathf.RoundToInt 는 짝수 반올림(8888.5 → 8888)이라 피해 표기에 맞지 않는다 — 0.5 는 올린다. sb.Append((long)System.Math.Round( s.resultMaxDamageUseBurst ? s_result.maxDamageBurst : s_result.maxDamage, System.MidpointRounding.AwayFromZero)); break; case WLRunResultRow.Level: AppendFormat2(sb, s.rowLevelFormat, s_result.levelFrom, s_result.levelTo); break; case WLRunResultRow.LootByGrade: AppendLootByGrade(sb, s); break; 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) { if (sec < 0f) { sb.Append(s.resultDashText); return; } int n = RunHud.FormatMMSS(s_timeBuf, sec); sb.Append(s_timeBuf, 0, n); } /// "{0} / {1}" 같은 두 자리 치환(정수 전용 · string.Format 없이). private static void AppendFormat2(StringBuilder sb, string format, long a0, long a1) { if (string.IsNullOrEmpty(format)) { sb.Append(a0).Append(' ').Append(a1); return; } for (int i = 0; i < format.Length; i++) { if (format[i] == '{' && i + 2 < format.Length && format[i + 2] == '}') { char c = format[i + 1]; if (c == '0') { sb.Append(a0); i += 2; continue; } if (c == '1') { sb.Append(a1); i += 2; continue; } } sb.Append(format[i]); } } /// 등급별 획득 — 0 인 등급은 생략 · 색은 813f/813y 등급 팔레트(밝기 하한 적용). private static void AppendLootByGrade(StringBuilder sb, WLRunUiSettings s) { var text = WLCombatTextSettings.Instance; int shown = 0; for (int g = 1; g < s_lootLen; g++) // 인덱스 = 등급(1~9) · [0] 미사용(813p 계약) { int n = s_lootByGrade[g]; if (n <= 0) continue; if (shown > 0) sb.Append(s.rowLootGradeSeparator); if (text != null) sb.Append(text.GradeColorTagBright(g)); AppendFormat2(sb, s.rowLootGradeFormat, n, g); if (text != null) sb.Append(""); shown++; } if (shown == 0) sb.Append(s.rowLootEmptyText); } // ── 조립 (런타임 생성 · 프리팹 diff 0 · 1회) ────────────────────────── public static string Bind(Transform uiRoot) { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음"; if (!WLRunUiSettings.Enabled || !s.resultEnabled) return "off — 조립 생략"; if (uiRoot == null) return "uiRoot=null"; if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name; s_uiRoot = uiRoot; var prt = WLVignetteUtil.FindUiPath(uiRoot, s.resultParentPath) as RectTransform; if (prt == null) return "부모 없음 — 경로 \"" + s.resultParentPath + "\""; if (s_font == null) s_font = RunHud.BorrowFont(uiRoot); if (s_root == null || s_root.parent != prt) { var go = new GameObject("WL_RunResult", typeof(RectTransform)); go.layer = WLTextFxUtil.UILayer; go.hideFlags = HideFlags.DontSave; s_root = (RectTransform)go.transform; s_root.SetParent(prt, false); s_group = go.AddComponent(); var dimRt = WLVignetteUtil.NewChild(s_root, "Dim"); WLVignetteUtil.Stretch(dimRt); s_dim = dimRt.GetComponent(); if (s_dim == null) s_dim = dimRt.gameObject.AddComponent(); s_dim.raycastTarget = true; s_box = WLVignetteUtil.NewChild(s_root, "Box"); s_boxBg = s_box.GetComponent(); if (s_boxBg == null) s_boxBg = s_box.gameObject.AddComponent(); s_boxBg.raycastTarget = true; s_title = WLTextFxUtil.NewText(s_box, "Title", s_font, TextAlignmentOptions.Center); s_rowRoot = WLTextFxUtil.NewChild(s_box, "Rows"); s_nextButton = NewButton(s_box, "btn_next", out s_nextBg, out s_nextLabel, OnNextRunListener); 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 + " · sortingOrder=" + SortingOrder; } private static Button NewButton(RectTransform parent, string name, out Image bg, out TextMeshProUGUI label, UnityEngine.Events.UnityAction onClick) { var t = WLVignetteUtil.NewChild(parent, name); bg = t.GetComponent(); if (bg == null) bg = t.gameObject.AddComponent(); bg.raycastTarget = true; var b = t.GetComponent