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

978 lines
54 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// ─────────────────────────────────────────────────────────────────────────────
// 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).
//
// ■ WL-813q3(D-44) — 「보스 처치 : 미처치 (HP 남음 N %)」 의 원천을 바꿨다.
// 813w6 이 `RunEnded` 페이로드에 **`bossHpRatio`/`bossHp`/`bossHpMax`** 를 실었다(`RunEvents.cs` 실측 ·
// 순서 = HP 스냅샷 → `RunEnded` 발행 → `BossArena.ParkBoss`(만피 복구)). 813q2 는 이 필드가 없어
// **표시 시점**에 `BossHpBar.RunBossHpRatio` 를 읽었고, 부활 미룸(D-18)으로 결과가 늦게 그려지면
// 그 사이 Park 이 돌아 **만피 100 %** 가 찍혔다(813w6 보고 §6-1 「잔여」).
// 이제 `CopyResult` 가 런 종료 순간에 구조체째 복사한 **`s_result.bossHpRatio`** 를 쓴다
// → 늦게 그려도 스냅샷 값 그대로. 페이로드가 1(보스 미등장·못 읽음)이면 813q2 폴백으로 간다.
// C8 = `resultBossHpFromPayload = 0` → 813q2 동작(만피 재현). 원천은 `LastBossHpSource` 로 읽는다.
// 같은 발주로 「카드 선택」 행(813o `ZonePickup.PicksThisRun` · 읽기만)을 추가했다.
//
// ■ 프리팹 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
{
/// <summary>런 결과 화면(정적 · 노드는 런타임 생성 · 1회 만들고 재사용).</summary>
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 int s_cardPicksAtEnd; // WL-813q3 — RunEnded 순간의 813o 카드 선택 수 스냅샷
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 = "";
// ── WL-813q3(D-44) 진단 ──────────────────────────────────────────────
/// <summary>마지막 「보스 처치」 행이 남은 HP 를 어디서 읽었나 — "페이로드"(813w6) / "UI바"(813q2) / "없음".</summary>
public static string LastBossHpSource = "없음";
/// <summary>런 종료 순간의 보스 HP 스냅샷(813w6 페이로드 · 1 = 미발생) — 늦게 그려도 안 바뀐다.</summary>
public static float BossHpRatioAtEnd { get { return s_result.bossHpRatio; } }
public static double BossHpAtEnd { get { return s_result.bossHp; } }
public static double BossHpMaxAtEnd { get { return s_result.bossHpMax; } }
/// <summary>런 종료 순간의 813o 카드 선택 수 스냅샷.</summary>
public static int CardPicksAtEnd { get { return s_cardPicksAtEnd; } }
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; }
/// <summary>복사해 둔 결과(배열 3종은 **자기 버퍼**를 가리킨다).</summary>
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(); // 다음 런에서 재정렬이 남지 않게 되돌린다
}
/// <summary>엘리트 처치 집계(🔴 813p RunResult 에 엘리트 필드가 없다 · 문자열 0 = GC 0).</summary>
private static void OnKilled(in KilledEvent e)
{
if (e.subRole != eSubRol.Elite) return;
EliteKilledSeen++;
if (!RunDirector.IsRunning) return; // 런 밖 처치는 이번 런 값이 아니다
s_eliteKills++;
}
// ── WL-813q2(D-18) 사망/부활 ──────────────────────────────────────────
/// <summary>사망 시작(813i `DeathFlow.Died`) — 런 밖이면 팝업을 생략하고 즉시 부활시킨다.</summary>
private static void OnDied(in DeathEvent e)
{
DiedSeen++;
HandleDeathStart("Died");
}
/// <summary>부활 팝업 요청(813i `DeathFlow.ReviveRequested`) — Died 를 놓친 경우의 2차 방어.</summary>
private static void OnReviveRequested(in ReviveRequestEvent e)
{
ReviveRequestSeen++;
HandleDeathStart("ReviveRequested");
}
/// <summary>런 밖 사망이면 즉시 부활 · 아니면 이미 떠 있는 결과 화면을 부활 뒤로 재정렬.</summary>
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;
}
/// <summary>부활 완료(813i `DeathFlow.Revived` · 워치독 자동 부활 포함) — 결과를 다시 앞으로.</summary>
private static void OnRevived(in RevivedEvent e)
{
RevivedSeen++;
RaiseToFront(); // 미뤄 둔 결과는 러너 Tick 이 곧 띄운다
}
private static void OnRunEnded(in RunResult e)
{
EndedSeen++;
s_eliteKillsAtEnd = s_eliteKills; // WL-813q2 — 결과 화면이 쓰는 값은 이 순간의 스냅샷
s_cardPicksAtEnd = ZonePickup.PicksThisRun; // WL-813q3 — 813o 카운터도 이 순간에 뜬다(읽기만)
// 🔴 WL-813q3 — 배열 3종 + **보스 HP 스냅샷(813w6 `e.bossHpRatio`)** 을 즉시 자기 버퍼로 복사.
// 이 뒤에 BossArena 가 미뤄 둔 Park(만피 복구)을 돌려도 결과창 값은 안 바뀐다.
CopyResult(in e);
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);
}
/// <summary>🔴 재사용 배열 3종을 자기 버퍼로 복사하고 구조체의 배열 필드를 갈아 끼운다.</summary>
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)];
}
// ── 표시 ──────────────────────────────────────────────────────────────
/// <summary>
/// WL-813q2(D-18) — 「부활 우선」 판정. **주 판정 = 813i `DeathFlow.State`**(Dying/AwaitingRevive),
/// `ReviveDialog.Busy`(공용 팝업 경로 · 813q)는 보조. 둘 다 SO 로 끌 수 있다.
/// </summary>
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;
}
}
/// <summary>「부활 우선」이 걸린 이유(실측 덤프용 · 표시 경로가 아니다).</summary>
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 "없음";
}
}
/// <summary>부활 팝업이 비면 띄운다. 아직 떠 있으면 미뤄 둔다(러너가 다시 부른다).</summary>
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)보다 아래로 ───
/// <summary>결과 화면을 부활 팝업 뒤로 내린다(sortingOrder 낮춤 + 형제 맨 앞).</summary>
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;
}
/// <summary>재정렬을 되돌린다(부활 완료·다음 런).</summary>
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;
}
/// <summary>전용 Canvas(overrideSorting) 보장 — 813z 부활 팝업과 같은 방식.</summary>
private static void EnsureCanvas(WLRunUiSettings s)
{
if (s == null || s_root == null || !s.resultOwnCanvas) return;
if (s_canvas == null)
{
s_canvas = s_root.GetComponent<Canvas>();
if (s_canvas == null) s_canvas = s_root.gameObject.AddComponent<Canvas>();
}
if (!s_canvas.overrideSorting) s_canvas.overrideSorting = true;
if (s_raycaster == null)
{
s_raycaster = s_root.GetComponent<GraphicRaycaster>();
if (s_raycaster == null) s_raycaster = s_root.gameObject.AddComponent<GraphicRaycaster>();
}
}
/// <summary>지금 바로 결과 화면을 띄운다(공용 팝업 스위치 → 런타임 패널).</summary>
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;
}
/// <summary>공용 Popup 싱글턴(SortOrder_5) 재사용 — ReviveDialog 와 같은 시도/폴백 구조.</summary>
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; }
}
/// <summary>[다음 런] — 813p 공개 API 하나만 부른다.</summary>
public static string OnNextRun()
{
NextRunCount++;
Hide();
try { RunDirector.Restart(); }
catch (System.Exception e) { return "다음 런 — RunDirector.Restart() 예외(" + e.GetType().Name + ")"; }
return "다음 런 — RunDirector.Restart() 호출";
}
/// <summary>[닫기].</summary>
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";
}
// ── 본문 ──────────────────────────────────────────────────────────────
/// <summary>마지막으로 만든 본문 전체 문자열(공용 팝업 · 실측 덤프용).</summary>
public static string BodyText { get; private set; } = "";
/// <summary>행 종류별 마지막 값 문자열(실측 덤프용).</summary>
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 "";
}
/// <summary>그 행을 이번 결과에 실제로 그리는가(레벨 0 숨김 등).</summary>
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;
}
/// <summary>행 라벨·값을 채운다(런 종료 1회 · 틱 경로가 아니다).</summary>
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;
case WLRunResultRow.CardPicks: return s.rowCardPicksLabel;
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;
case WLRunResultRow.CardPicks:
sb.Append(s.resultCountCardPicksInUi ? s_cardPicksAtEnd : 0);
break;
default:
AppendFormat2(sb, s.rowDeathsFormat, s_result.deaths, s_result.potionsUsed);
break;
}
}
/// <summary>
/// WL-813q3 §1-1 — 보스 처치 행. 처치했으면 mm:ss, 아니면 "미처치 (HP 남음 N %)".
/// 🔴 남은 HP 의 원천이 바뀌었다: **`RunEnded` 페이로드 `bossHpRatio`**(813w6 D-44 스냅샷 · 0~1 · 1 = 미발생).
/// `CopyResult` 가 런 종료 순간에 구조체째 복사해 두므로 **부활 미룸(813q2 D-18)으로 결과가 늦게 그려져도**
/// 그 사이 `BossArena.ParkBoss` 가 보스를 만피로 되돌린 것과 무관하게 스냅샷 값 그대로다.
/// 페이로드가 1(보스 미등장 · 못 읽음)이면 813q2 폴백(`BossHpBar.RunBossHpRatio` · UI 자체 실측)으로 간다.
/// 둘 다 없으면 퍼센트 없이 "미처치"만 쓴다. 원천은 `LastBossHpSource` 로 실측한다.
/// </summary>
private static void AppendBossClear(StringBuilder sb, WLRunUiSettings s)
{
if (s_result.bossClearSec >= 0f) { AppendTime(sb, s_result.bossClearSec, s); return; }
// ① 813w6 페이로드 스냅샷(Park 이전 값) → ② 813q2 UI 실측 폴백 → ③ 없음
float ratio = -1f;
LastBossHpSource = "없음";
if (s.resultBossHpFromPayload && s_result.bossHpRatio >= 0f)
{
ratio = s_result.bossHpRatio;
LastBossHpSource = "페이로드"; // RunEnded.bossHpRatio (813w6)
}
else if (s.resultBossHpFromUi)
{
ratio = BossHpBar.RunBossHpRatio;
if (ratio >= 0f) LastBossHpSource = "UI바"; // BossHpBar.RunBossHpRatio (813q2)
}
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);
}
/// <summary>초 → mm:ss (음수 = 미발생 → 대시 문구).</summary>
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);
}
/// <summary>"{0} / {1}" 같은 두 자리 치환(정수 전용 · string.Format 없이).</summary>
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]);
}
}
/// <summary>등급별 획득 — 0 인 등급은 생략 · 색은 813f/813y 등급 팔레트(밝기 하한 적용).</summary>
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("</color>");
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<CanvasGroup>();
var dimRt = WLVignetteUtil.NewChild(s_root, "Dim");
WLVignetteUtil.Stretch(dimRt);
s_dim = dimRt.GetComponent<Image>();
if (s_dim == null) s_dim = dimRt.gameObject.AddComponent<Image>();
s_dim.raycastTarget = true;
s_box = WLVignetteUtil.NewChild(s_root, "Box");
s_boxBg = s_box.GetComponent<Image>();
if (s_boxBg == null) s_boxBg = s_box.gameObject.AddComponent<Image>();
s_boxBg.raycastTarget = true;
s_title = WLTextFxUtil.NewText(s_box, "Title", s_font, TextAlignmentOptions.Center);
s_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<Image>();
if (bg == null) bg = t.gameObject.AddComponent<Image>();
bg.raycastTarget = true;
var b = t.GetComponent<Button>();
if (b == null) b = t.gameObject.AddComponent<Button>();
b.targetGraphic = bg;
b.onClick.RemoveListener(onClick);
b.onClick.AddListener(onClick);
label = WLTextFxUtil.NewText(t, "Label", s_font, TextAlignmentOptions.Center);
WLTextFxUtil.Stretch(label.rectTransform);
return b;
}
/// <summary>행 노드를 설정의 행 수만큼 만든다(줄이지 않는다 · 재사용).</summary>
public static bool EnsureRows(WLRunUiSettings s)
{
if (s_rowRoot == null) return false;
int want = s != null && s.resultRows != null ? s.resultRows.Length : 0;
if (s_row.Length >= want) return false;
int old = s_row.Length;
System.Array.Resize(ref s_row, want);
System.Array.Resize(ref s_rowLabel, want);
System.Array.Resize(ref s_rowValue, want);
for (int i = old; i < want; i++)
{
var rt = WLTextFxUtil.NewChild(s_rowRoot, "Row" + i);
s_row[i] = rt;
s_rowLabel[i] = WLTextFxUtil.NewText(rt, "Label", s_font, TextAlignmentOptions.MidlineLeft);
s_rowValue[i] = WLTextFxUtil.NewText(rt, "Value", s_font, TextAlignmentOptions.MidlineRight);
}
return true;
}
public static string ApplyLayout()
{
var s = St;
if (s == null) return "WLRunUiSettings 에셋 없음 — 배치 건너뜀";
if (s_root == null || s_box == null) return "노드 없음 — Bind 먼저";
var canvas = s_root.GetComponentInParent<Canvas>(true);
float u = s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null);
WLVignetteUtil.Stretch(s_root);
s_root.localScale = Vector3.one;
if (s_dim != null) s_dim.color = s.resultDimColor;
s_box.anchorMin = s_box.anchorMax = new Vector2(0.5f, 0.5f);
s_box.pivot = new Vector2(0.5f, 0.5f);
s_box.anchoredPosition = Vector2.zero;
s_box.sizeDelta = new Vector2(s.resultBoxSizePx.x * u, s.resultBoxSizePx.y * u);
s_box.localScale = Vector3.one;
if (s_boxBg != null) s_boxBg.color = s.resultBgColor;
if (s_title != null)
{
var rt = s_title.rectTransform;
rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(s.resultPadXPx * u, 0f); rt.offsetMax = new Vector2(-s.resultPadXPx * u, 0f);
rt.anchoredPosition = new Vector2(0f, -s.resultPadTopPx * u);
rt.sizeDelta = new Vector2(0f, s.resultTitleFontPx * u);
s_title.fontSize = s.resultTitleFontPx * u;
s_title.alignment = TextAlignmentOptions.Center;
WLTextFxUtil.ApplyEdge(s_title, s.resultEdge);
}
float rowTop = (s.resultPadTopPx + s.resultTitleFontPx + s.resultTitleGapPx) * u;
if (s_rowRoot != null)
{
s_rowRoot.anchorMin = new Vector2(0f, 1f); s_rowRoot.anchorMax = new Vector2(1f, 1f);
s_rowRoot.pivot = new Vector2(0.5f, 1f);
s_rowRoot.offsetMin = new Vector2(s.resultPadXPx * u, 0f); s_rowRoot.offsetMax = new Vector2(-s.resultPadXPx * u, 0f);
s_rowRoot.anchoredPosition = new Vector2(0f, -rowTop);
s_rowRoot.sizeDelta = new Vector2(0f, s.resultRowHeightPx * Mathf.Max(1, s_row.Length) * u);
for (int i = 0; i < s_row.Length; i++)
{
var rt = s_row[i];
if (rt == null) continue;
rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(0f, 0f); rt.offsetMax = new Vector2(0f, 0f);
rt.anchoredPosition = new Vector2(0f, -s.resultRowHeightPx * i * u);
rt.sizeDelta = new Vector2(0f, s.resultRowHeightPx * u);
rt.localScale = Vector3.one;
ApplyRowLabel(s_rowLabel[i], s, u, true);
ApplyRowLabel(s_rowValue[i], s, u, false);
}
}
LayoutButton(s_nextButton, s_nextBg, s_nextLabel, s, u, -1, s.resultNextRunText, s.resultNextBgColor, s.resultNextLabelColor);
LayoutButton(s_closeButton, s_closeBg, s_closeLabel, s, u, 1, s.resultCloseText, s.resultCloseBgColor, s.resultCloseLabelColor);
return "box size" + s_box.sizeDelta + " 행틀=" + s_row.Length + " unitsPerPx=" + u.ToString("F4");
}
private static void ApplyRowLabel(TextMeshProUGUI t, WLRunUiSettings s, float u, bool left)
{
if (t == null) return;
var rt = t.rectTransform;
rt.anchorMin = left ? new Vector2(0f, 0f) : new Vector2(0.35f, 0f);
rt.anchorMax = left ? new Vector2(0.35f, 1f) : new Vector2(1f, 1f);
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
rt.pivot = new Vector2(0.5f, 0.5f);
t.fontSize = s.resultRowFontPx * u;
t.alignment = left ? TextAlignmentOptions.MidlineLeft : TextAlignmentOptions.MidlineRight;
t.color = left ? s.resultRowLabelColor : s.resultRowValueColor;
WLTextFxUtil.ApplyEdge(t, s.resultEdge);
}
private static void LayoutButton(Button b, Image bg, TextMeshProUGUI label, WLRunUiSettings s, float u,
int side, string text, Color bgColor, Color labelColor)
{
if (b == null) return;
var rt = b.GetComponent<RectTransform>();
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0f);
rt.pivot = new Vector2(0.5f, 0f);
float half = (s.resultButtonSizePx.x + s.resultButtonGapPx) * 0.5f;
rt.anchoredPosition = new Vector2(side * half * u, s.resultButtonBottomPx * u);
rt.sizeDelta = new Vector2(s.resultButtonSizePx.x * u, s.resultButtonSizePx.y * u);
rt.localScale = Vector3.one;
if (bg != null) bg.color = bgColor;
if (label != null)
{
label.SetText(text);
label.fontSize = s.resultButtonFontPx * u;
label.color = labelColor;
label.alignment = TextAlignmentOptions.Center;
if (s_font != null && label.font != s_font) label.font = s_font;
}
}
/// <summary>폰트를 직접 지정한다(프로브·QA).</summary>
public static void SetFont(TMP_FontAsset f)
{
s_font = f;
if (f == null) return;
if (s_title != null) s_title.font = f;
if (s_nextLabel != null) s_nextLabel.font = f;
if (s_closeLabel != null) s_closeLabel.font = f;
for (int i = 0; i < s_row.Length; i++)
{
if (s_rowLabel[i] != null) s_rowLabel[i].font = f;
if (s_rowValue[i] != null) s_rowValue[i].font = f;
}
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
public static string ResetState()
{
EndedSeen = ShownCount = DeferredCount = NextRunCount = CloseCount = SkipAbandonCount = 0;
BufferGrowCount = 0;
DiedSeen = ReviveRequestSeen = RevivedSeen = SkipPopupReviveCount = 0;
LowerCount = RaiseCount = EliteKilledSeen = 0;
s_eliteKills = s_eliteKillsAtEnd = 0;
s_cardPicksAtEnd = 0; LastBossHpSource = "없음"; // WL-813q3
s_lowered = false; LastDeathLog = "";
s_pending = false; UsedCommonPopup = false; LastShowLog = "";
Hide();
return "상태 초기화";
}
public static string Teardown()
{
Subscribe(false);
if (s_root != null)
{
if (Application.isPlaying) Object.Destroy(s_root.gameObject);
else Object.DestroyImmediate(s_root.gameObject);
}
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];
s_uiRoot = null;
ResetState();
return "teardown 완료";
}
public static string Dump()
{
var s = St;
var sb = new StringBuilder();
sb.AppendLine("[RunResultUI] 구독=" + s_subscribed + " bound=" + Bound + " open=" + s_open +
" pending=" + s_pending + " · ended=" + EndedSeen + " shows=" + ShownCount +
" defer=" + DeferredCount + " next=" + NextRunCount + " close=" + CloseCount +
" 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);
sb.AppendLine(" [813q3] 보스HP 원천=" + LastBossHpSource + " · 페이로드 스냅샷 ratio=" +
(BossHpRatioAtEnd >= 0f ? (BossHpRatioAtEnd * 100f).ToString("F1") + "%" : "없음(1)") +
" hp=" + BossHpAtEnd.ToString("F0") + "/" + BossHpMaxAtEnd.ToString("F0") +
" · 카드 선택 종료스냅=" + s_cardPicksAtEnd + "(현재 " + ZonePickup.PicksThisRun + ")");
if (s != null && s.resultRows != null)
for (int i = 0; i < s.resultRows.Length; i++)
{
var kind = s.resultRows[i];
if (!RowApplies(s, kind)) { sb.AppendLine(" - " + kind + " : (숨김)"); continue; }
sb.AppendLine(" - " + kind + " : \"" + RowValueText(kind) + "\"");
}
sb.AppendLine(" 복사본 outcome=" + s_result.outcome + " runIndex=" + s_result.runIndex +
" total=" + s_result.totalSec.ToString("F1") + "s bossClear=" + s_result.bossClearSec.ToString("F1") +
"s kills=" + s_result.kills + "(보스 " + s_result.bossKills + ") chain=" + s_result.maxChain +
" dmg=" + s_result.maxDamage.ToString("F1") + "/" + s_result.maxDamageBurst.ToString("F1") +
" lv=" + s_result.levelFrom + "→" + s_result.levelTo + " lootTotal=" + s_result.lootTotal +
" gold=" + s_result.gold + " deaths=" + s_result.deaths + " potions=" + s_result.potionsUsed);
sb.Append(" 복사 배열 zone(" + s_zoneLen + "):");
for (int i = 0; i < s_zoneLen; i++) sb.Append(" [").Append(i).Append("]=").Append(s_zoneClearSec[i].ToString("F1")).Append('/').Append(s_zoneKills[i]);
sb.Append(" · loot(" + s_lootLen + "):");
for (int i = 0; i < s_lootLen; i++) sb.Append(' ').Append(s_lootByGrade[i]);
sb.AppendLine();
sb.AppendLine(" 본문 = " + BodyText.Replace("\n", " | "));
if (s != null)
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 +
" · [813q2] 사망중미룸=" + s.resultDeferWhileDying + " 런밖즉시부활=" + s.skipRevivePopupAfterRunEnd +
" 전용캔버스=" + s.resultOwnCanvas + "(" + s.resultSortingOrder + "→" + s.resultSortingOrderWhileRevive + ")" +
" 재정렬=" + s.resultLowerWhileRevive + "/" + s.resultRaiseAfterRevive +
" 엘리트UI집계=" + s.resultCountElitesInUi + " 보스HP UI=" + s.resultBossHpFromUi +
" · [813q3] 보스HP 페이로드=" + s.resultBossHpFromPayload +
" 카드선택UI집계=" + s.resultCountCardPicksInUi);
return sb.ToString();
}
}
}