// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// 런 결과 화면(정적 · 노드는 런타임 생성 · 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 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];
// ── WL-815d 스테이지 모드 ─────────────────────────────────────────────
// 기준서 §F-1 — 같은 패널을 스테이지 클리어 화면으로 쓴다.
// · 제목 = "스테이지 N 클리어!" · 행 = `WLStageUiSettings.stageResultRows`
// · 버튼 = [다음 스테이지] → StageDirector.Next() / [로비로] → 「로비로」(StageUiBridge 가 대입)
// 🔴 값의 출처는 **`StageClearResult` 페이로드 + UI 자체 집계**다. 815b `mirrorRunEnded` 가
// 내보내는 RunResult 에는 레벨·골드·등급별 획득이 없다(실측) → 그 행은 기본 배열에서 뺐다.
private static bool s_stageMode;
private static WL.Combat.Stage.StageClearResult s_stage;
private static bool s_hasStage;
private static int s_stageHits, s_stagePotions, s_stageMaxChain;
private static TextMeshProUGUI s_badge; // 무피격 클리어 배지
private static bool s_suppressNextRunEnded; // 실패 경로 — 병행 RunEnded 로 결과창이 겹쳐 뜨는 것을 막는다
// ── WL-816m 던전 모드 (#816) ─────────────────────────────────────────
// 🔴 이 파일은 던전인지 **스스로 판정하지 않는다** — 유일한 이음매 `StageUiBridge` 가
// `WLDungeonSceneSetup.Current` 를 보고 이름·골드를 넘겨준다(UI ↔ 게임 로직 분리).
// s_dungeonName == null 이면 #815 스테이지 모드 그대로다(한 글자도 안 바뀐다).
private static string s_dungeonName;
private static int s_dungeonGold = -1; // -1 = 모름(보상 축이 꺼져 있거나 던전이 아님)
/// 815d 가 대입한다 — [다음 스테이지] / [처음부터].
public static System.Action StagePrimary;
/// 815d 가 대입한다 — [로비로].
public static System.Action StageSecondary;
public static int StagePrimaryCount, StageSecondaryCount, StageShownCount, SuppressedRunEndedCount;
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
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) 진단 ──────────────────────────────────────────────
/// 마지막 「보스 처치」 행이 남은 HP 를 어디서 읽었나 — "페이로드"(813w6) / "UI바"(813q2) / "없음".
public static string LastBossHpSource = "없음";
/// 런 종료 순간의 보스 HP 스냅샷(813w6 페이로드 · −1 = 미발생) — 늦게 그려도 안 바뀐다.
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; } }
/// 런 종료 순간의 813o 카드 선택 수 스냅샷.
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; }
/// 복사해 둔 결과(배열 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 — 결과 화면이 쓰는 값은 이 순간의 스냅샷
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;
// WL-815d — 스테이지 실패(사망·시간 초과)는 813z 2버튼 팝업이 맡는다. 815b `mirrorRunEnded` 가
// 같은 프레임에 내보내는 RunEnded(Timeout/Abandon)로 결과창이 **겹쳐 뜨는 것**을 여기서 막는다.
if (s_suppressNextRunEnded)
{
s_suppressNextRunEnded = false;
SuppressedRunEndedCount++;
s_pending = false;
if (s.verboseLog) Debug.Log("[RunResultUI] 815d — 스테이지 실패 경로: 병행 RunEnded 억제");
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