From a029f4f490120f5734db6c53e3c35fa21002fece Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 09:15:05 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813q]=20=EB=9F=B0=20HUD=20=C2=B7=20?= =?UTF-8?q?=EB=9F=B0=20=EA=B2=B0=EA=B3=BC=20=ED=99=94=EB=A9=B4=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 런 HUD(RunHud) — 813p RunEvents 6종 구독 · 남은 시간 mm:ss(마지막 30s 경고색) · 존 게이지 zoneCount 칸(진행 fill + 클리어 0.3s 펄스) · 단계 라벨("존 N" → 게이트 뒤 "BOSS"). 런 결과 화면(RunResultUI) — RunEnded 수신 → Win "런 클리어!" / Timeout "시간 종료" / Abandon 팝업 0. 본문 9행(총 시간·보스 처치·처치 수·최대 연쇄·최대 피해(합산)·레벨·등급별 획득·골드·사망/물약) · [다음 런]=RunDirector.Restart() · [닫기]. 배열 3종(zoneClearSec·zoneKills·lootByGrade)은 RunEnded 즉시 자기 버퍼로 복사(813p 재사용 계약) — 두 번째 런부터 할당 0. 값 전부 SO(WLRunUiSettings.asset NEW · 코드 상수 0) · C8(enabled_=0) · GC 0(틱 문자열 0 · mm:ss char 버퍼) · 프리팹/씬 수정 0(813y 런타임 생성). Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL813q_PROBE.txt | 175 +++++ AgentScripts/WL813q_Probe.cs | 555 ++++++++++++++ Assets/WL/UI/Scripts/ReviveDialog.cs | 6 + Assets/WL/UI/Scripts/RunHud.cs | 635 ++++++++++++++++ Assets/WL/UI/Scripts/RunHud.cs.meta | 2 + Assets/WL/UI/Scripts/RunResultUI.cs | 698 ++++++++++++++++++ Assets/WL/UI/Scripts/RunResultUI.cs.meta | 2 + .../Resources/WL/WLRunUiSettings.asset | 126 ++++ .../Resources/WL/WLRunUiSettings.asset.meta | 8 + Assets/WL/UI/Settings/WLRunUiSettings.cs | 407 ++++++++++ Assets/WL/UI/Settings/WLRunUiSettings.cs.meta | 2 + 11 files changed, 2616 insertions(+) create mode 100644 AgentScripts/WL813q_PROBE.txt create mode 100644 AgentScripts/WL813q_Probe.cs create mode 100644 Assets/WL/UI/Scripts/RunHud.cs create mode 100644 Assets/WL/UI/Scripts/RunHud.cs.meta create mode 100644 Assets/WL/UI/Scripts/RunResultUI.cs create mode 100644 Assets/WL/UI/Scripts/RunResultUI.cs.meta create mode 100644 Assets/WL/UI/Settings/Resources/WL/WLRunUiSettings.asset create mode 100644 Assets/WL/UI/Settings/Resources/WL/WLRunUiSettings.asset.meta create mode 100644 Assets/WL/UI/Settings/WLRunUiSettings.cs create mode 100644 Assets/WL/UI/Settings/WLRunUiSettings.cs.meta diff --git a/AgentScripts/WL813q_PROBE.txt b/AgentScripts/WL813q_PROBE.txt new file mode 100644 index 000000000..a8eb3904d --- /dev/null +++ b/AgentScripts/WL813q_PROBE.txt @@ -0,0 +1,175 @@ +# WL813q Probe 2026-09-09 09:14:01 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 에셋 (WLRunUiSettings · NEW) + [PASS] Resources.Load("WL/WLRunUiSettings") + [PASS] Enabled(에셋·enabled_·RuntimeDisabled) = True + 값: enabled_=True hud=True result=True topMargin=272px width=560px timerPx=56 warn=30s pulse=1.35×0.3s + 문구: win="런 클리어!" timeout="시간 종료" next="다음 런" close="닫기" zone="존 {0}" boss="BOSS" + 행 순서: TotalTime · BossClear · Kills · MaxChain · MaxDamage · Level · LootByGrade · Gold · DeathsPotions + [PASS] 본문 행 9종(발주서 §1-2 순서) + [PASS] Abandon 팝업 없음(기본값) + [PASS] 부활 팝업 우선(기본값) + [PASS] 813p WLRunSettings 존재(런 길이·존 수의 주인) + 813p 값(읽기만): runSeconds=240 zoneSpawnerIds=4 zoneClearKills=[12,14,12,8] + [PASS] WLLootSettings.GradeSlots = 9 → lootByGrade 길이 10(813p 계약) + +── ① 노드 — NewGameUI 프리팹(저장 0) · Safe Area · 보스 바와 겹침 + [PASS] LoadPrefabContents(Assets/Res_Addr/MainUI/NewGameUI.prefab) + [PASS] IngameUIs/WL_HUD 경로 실측 + [PASS] WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안 (813y 실측 재확인) + Bind HUD : 연결 IngameUIs/WL_HUD · pos(0.00, -483.56) size(995.56, 216.89) 칸=4 unitsPerPx=1.7778 + Bind 결과 : 연결 IngameUIs/WL_HUD · box size(1528.89, 1920.00) 행틀=9 unitsPerPx=1.7778 + [PASS] RunHud 노드 생성(WL_RunHud · HideFlags.DontSave) + [PASS] RunResultUI 노드 생성(WL_RunResult · HideFlags.DontSave) + [PASS] RunStarted 구독자 2(HUD+결과) — 실측 2 + [PASS] 6종 구독 배선 = Tick1/Zone1/Gate1/Boss1/Ended2 + 보스 바 = 상단 190 ~ 256 px · 런 HUD 상단 = 272 px + [PASS] 런 HUD 가 보스 HP 바 아래(겹침 0 · 여유 16.0 px) + HUD 배치 : pos(0.00, -483.56) size(995.56, 216.89) 칸=4 unitsPerPx=1.7778 + 결과 배치: box size(1528.89, 1920.00) 행틀=9 unitsPerPx=1.7778 + +── ② HUD — RunStarted → RunTick×3 → ZoneCleared×4 → BossGateOpened → BossStarted + [PASS] RunStarted 수신 1 + [PASS] 존 칸 4개 생성 — 실측 4 + [PASS] HUD 표시 on + [PASS] 타이머 리셋 = 04:00(runSeconds 240 · 페이로드에서) — 실측 "04:00" + [PASS] 단계 = "존 1" — 실측 "존 1" + tick1 timer="03:20" 존0 fill=0.50 단계="존 1" + [PASS] 틱1 mm:ss = 03:20 + [PASS] 틱1 존0 게이지 = 6/12 = 0.50 — 실측 0.500 + [PASS] 틱2 mm:ss = 02:30 + [PASS] 틱2 존0=1.00(클리어) · 존1=0.50 — 실측 1.00/0.50 + [PASS] 틱2 단계 = "존 2" — 실측 "존 2" + [PASS] 틱3 mm:ss = 00:25 + [PASS] 마지막 30s 경고색 #FF4A47 — 실측 #FF4A47 + [PASS] ZoneCleared 4회 수신 + [PASS] 존 칸 4개 전부 채움 + 펄스 실측: 시작 scale=1.350 → 0.3s 뒤 1.000 + [PASS] 존 클리어 펄스 1.35× → 0.3s 안에 1.0 복귀 + [PASS] 게이트 개방 → 단계 라벨 "BOSS" + [PASS] 보스 단계 색 #FF9E3D + [PASS] BossStarted 수신 1 (boss=null 계약 경로 · 예외 0) + [RunHud] 구독=True bound=True visible=True · started=1 tick=3 zoneCleared=4 gate=1 boss=1 ended=0 + 타이머 "00:25" (남은 25.0s · 경고=True · 쓰기 4회) · 단계 "BOSS" (쓰기 4회) + 존 칸 4 (fill 쓰기 15회): [0]=1.00×1.00 [1]=1.00×1.00 [2]=1.00×1.00 [3]=1.00×1.00 + 노드 pos(0.00, -483.56) size(995.56, 216.89) active=True 부모=WL_HUD SafeArea=True + edge = Font 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 + +── ③ 결과 화면 — RunEnded(Win) + [PASS] RunEnded 수신 1 + [PASS] 결과 화면 표시(부활 팝업 없음) + [PASS] 제목 = "런 클리어!" — 실측 "런 클리어!" + [PASS] 런 종료 → HUD 숨김(runHudHideOnEnd) + [PASS] 복사본 스칼라 = 입력과 일치(outcome/runIndex/total/kills/boss/chain/level/gold/deaths/potions/zones) + 행 값 실측: + 총 시간 = "02:34" + 보스 처치 = "02:34" + 처치 수 = "99 (보스 1)" + 최대 연쇄 = "9" + 최대 피해 = "8889" + 레벨 = "11 → 14" + 등급별 = "7 3 2 1" + 골드 = "777" + 사망/물약 = "2 / 3" + [PASS] 총 시간 154s → 02:34 + [PASS] 보스 처치 153.5s → 02:34(올림) + [PASS] 처치 수 = 99 (보스 1) + [PASS] 최대 연쇄 = 9 + [PASS] 최대 피해 = 합산(maxDamageBurst 8888.5 → 8889 · 0.5 는 올림) · 단일(1234.5)이 아니다 — 실측 "8889" + [PASS] 레벨 11 → 14 + [PASS] 골드 = 777 + [PASS] 사망/물약 = 2 / 3 + [PASS] 등급별 획득 = 1급 7 · 2급 3 · 4급 2 · 9급 1 (0 인 등급 생략 · 4칸) + [PASS] 등급 색 태그 4개(813f 팔레트 + 813y 밝기 하한) — 실측 4 + 등급 색: 1= 2= 4= 9= + [PASS] 본문 9행 표시 — 실측 9 + [RunResultUI] 구독=True bound=True open=True pending=False · ended=1 shows=1 defer=0 next=0 close=0 abandon생략=0 버퍼증설=0 공용팝업=False + 제목 "런 클리어!" · 행 9개 · 부활팝업Busy=False + - TotalTime : "02:34" + - BossClear : "02:34" + - Kills : "99 (보스 1)" + - MaxChain : "9" + - MaxDamage : "8889" + - Level : "11 → 14" + - LootByGrade : "7 3 2 1" + - Gold : "777" + - DeathsPotions : "2 / 3" + 복사본 outcome=Win runIndex=2 total=154.0s bossClear=153.5s kills=99(보스 1) chain=9 dmg=1234.5/8888.5 lv=11→14 lootTotal=13 gold=777 deaths=2 potions=3 + 복사 배열 zone(4): [0]=31.5/12 [1]=62.3/14 [2]=95.0/12 [3]=-1.0/5 · loot(10): 0 7 3 0 2 0 0 0 0 1 + 본문 = 런 클리어! | 총 시간 : 02:34 | 보스 처치 : 02:34 | 처치 수 : 99 (보스 1) | 최대 연쇄 : 9 | 최대 피해 : 8889 | 레벨 : 11 → 14 | 획득 : 7 3 2 1 | 골드 : 777 | 사망 / 물약 : 2 / 3 | + 설정 enabled=True result=True 공용팝업=False abandon표시=False 부활우선=True box=(860.00, 1080.00) 행수=9 합산피해=True + +── ④ 🔴 배열 3종 즉시 복사(813p 재사용 계약) — 원본을 덮어써도 화면 값 불변 + [PASS] zoneClearSec 참조가 원본과 다르다(자기 버퍼) + [PASS] zoneKills 참조가 원본과 다르다(자기 버퍼) + [PASS] lootByGrade 참조가 원본과 다르다(자기 버퍼) + [PASS] 존 배열 값 불변 — clearSec[0]=31.50 kills[0]=12 (원본은 −999 로 덮어씀) + [PASS] loot 배열 값 불변 — [1]=7 (원본은 999) + [PASS] 결과 화면 등급 줄 문자열 불변 + 원본 배열 현재값: zoneClearSec[0]=-999 zoneKills[0]=-999 loot[1]=999 + [PASS] 두 번째 복사에서 버퍼 증설 0(런마다 배열 할당 0) — 증설 누계 0 + +── ⑤ 시간 종료(Timeout) + [PASS] 결과 화면 표시 + [PASS] 제목 = "시간 종료" — 실측 "시간 종료" + [PASS] 보스 처치 −1 → "—"(미발생 대시) + [PASS] 보스 처치 0 + +── ⑥ 이탈(Abandon) — 팝업 없음 · HUD 리셋만 + [PASS] 팝업 0 (open=False) + [PASS] 표시 호출 0 (누계 2) + [PASS] Abandon 생략 카운터 +1 + [PASS] 대기(pending) 0 — 나중에 저절로 뜨지 않는다 + [PASS] HUD 숨김(리셋) + +── ⑦ 813i/813tj 부활 팝업과 동시 — 부활 우선 · 결과는 닫힌 뒤 + [PASS] 프리팹에서 ReviveDialog 발견 — 1개 + ReviveDialog.Initialize : initialized · 폴백 팝업 box size(1351.11, 675.56) unitsPerPx=1.7778 + ReviveDialog.ShowNowOnce: 폴백 팝업 표시 msg="존 시작점에서 부활합니다. +무료" + [PASS] 부활 팝업 Busy = True + [PASS] 결과 화면 아직 안 뜬다(부활 우선) + [PASS] 결과 대기 중 = True + [PASS] 미룸 카운터 +1 + [PASS] 부활 팝업 닫힘 → Busy=false + Tick: 런타임 패널 표시 outcome=Win 행=9 + [PASS] 부활 팝업이 닫힌 뒤 결과 화면 표시 + [PASS] 대기 해제 + [다음 런] : 다음 런 — RunDirector.Restart() 호출 + [PASS] [다음 런] 클릭 경로 = RunDirector.Restart() 1회 + [PASS] [다음 런] 뒤 결과 화면 닫힘 + RunDirector 상태: phase=Zones runIndex=4 restartCount=4 + [닫기] : 닫기 + [PASS] [닫기] 뒤 결과 화면 닫힘 + +── ⑧ GC — 틱 100회 할당 0 (대조군 2종) + 계기 = GC.GetTotalMemory (Boehm 힙 크기) + GC.CollectionCount(0) · GetAllocatedBytesForCurrentThread 절대값=0 (0 이면 이 런타임에 미구현 → 쓰지 않는다) + 본시험(같은 초 100틱): ΔGetTotalMemory=0 B · gc0 340→340 · 표시 쓰기 31→31 (Δ할당 계기 참고값 0 B · 미구현) + [PASS] 🔴 구조 증명: 값이 그대로면 SetText/fillAmount 쓰기 0회 = 만들 문자열 자체가 없다 + [PASS] ΔGC = 0 B · gc0 불변 (RunTick 100회) + 대조군 A(초가 바뀌는 100틱 · SetText 99회): ΔGetTotalMemory=0 B · gc0 343→343 — 우리 코드의 mm:ss 는 char 버퍼(문자열 0)이고, 에디터에서는 TMP 가 `#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로 인스펙터용 문자열을 만든다(빌드에는 없는 줄). + 대조군 B(40,000자 문자열 200개 ≈ 16 MB): ΔGetTotalMemory=16384000 B · gc0 346→346 (sink=200) — 계기 감도 확인. 100 KB 규모는 Boehm 힙 여유분에 묻혀 Δ0 이 나온다(실측). + [PASS] 측정 감도 있음(대조군 B ΔGetTotalMemory > 0) + [PASS] mm:ss 경계값(0 · 음수 · 올림 · 240 · 상한 99:59) + +── ⑨ C8 롤백 — enabled = 0 이면 HUD·결과 0 · 813p 는 그대로 + [PASS] RuntimeDisabled=1 → Enabled=False + HUD Tick: off — 무동작 + 결과 Tick: off — 무동작 + [PASS] HUD 숨김 · 결과 닫힘 + Bind(off): off — 조립 생략 + [PASS] asset.enabled_=0 → Enabled=False (같은 게이트) + [PASS] 복구 후 Enabled=True + [PASS] 813p WLRunSettings.Enabled 불변 = True (런 구조는 그대로 동작) + +── ⑩ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0 + RunHud : teardown 완료 + RunResultUI: teardown 완료 + [PASS] RunEvents 구독자 전부 0 (Started 0 Tick 0 Zone 0 Gate 0 Boss 0 Ended 0) + RunEvents.TotalRaised=11 last=RunTick · Dispatched(Tick)=820 + [PASS] TimeScale 무접촉 = 1 + UnloadPrefabContents — 프리팹 저장 0 + +RESULT PASS (pass=87 fail=0) diff --git a/AgentScripts/WL813q_Probe.cs b/AgentScripts/WL813q_Probe.cs new file mode 100644 index 000000000..7912b4a39 --- /dev/null +++ b/AgentScripts/WL813q_Probe.cs @@ -0,0 +1,555 @@ +// WL-813q 프로브 — 런 HUD + 런 결과 화면 실측 (에디트 모드 · Play 0 · 로그인 0). +// unity command run_script --file AgentScripts/WL813q_Probe.cs --entry WL813q_Probe.RunAll +// 산출물: /AgentScripts/WL813q_PROBE.txt +// 🔴 NewGameUI.prefab 은 LoadPrefabContents 로 열고 **저장하지 않는다**(UnloadPrefabContents(false)). +// HUD·결과 노드는 HideFlags.DontSave 라 프리팹에 굽히지 않는다. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine; +using UnityEngine.Profiling; +using UnityEditor; +using TMPro; +using WL.UI; +using WL.Combat.Run; +using WL.Combat.Loot; + +public static class WL813q_Probe +{ + const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; + + static StringBuilder _o; + static int _pass, _fail; + static GameObject _root; // 프리팹 미리보기 씬 루트(FindObjectsByType 는 미리보기 씬을 못 본다) + + static void H(string s) { _o.AppendLine(); _o.AppendLine("── " + s); } + static void N(string s) { _o.AppendLine(" " + s); } + static bool Chk(bool ok, string what) + { + if (ok) { _pass++; _o.AppendLine(" [PASS] " + what); } + else { _fail++; _o.AppendLine(" [FAIL] " + what); } + return ok; + } + + static string Root { get { return Directory.GetParent(Application.dataPath).FullName; } } + + public static string RunAll() + { + _o = new StringBuilder(); + _pass = _fail = 0; + _o.AppendLine("# WL813q Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); + _o.AppendLine("playMode=" + Application.isPlaying); + + GameObject prefabRoot = null; + bool savedRuntimeDisabled = WLRunUiSettings.RuntimeDisabled; + try + { + Step0_Asset(); + prefabRoot = Step1_Nodes(); + Step2_Hud(); + Step3_ResultWin(); + Step4_ArrayCopy(); + Step5_Timeout(); + Step6_Abandon(); + Step7_RevivePriority(); + Step8_Gc(); + Step9_C8(); + } + catch (Exception e) + { + _fail++; + _o.AppendLine(" [FAIL] 예외 " + e.GetType().Name + " : " + e.Message); + _o.AppendLine(e.StackTrace); + } + finally + { + WLRunUiSettings.RuntimeDisabled = savedRuntimeDisabled; + Step10_Cleanup(prefabRoot); + } + + _o.AppendLine(); + _o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " (pass=" + _pass + " fail=" + _fail + ")"); + string path = Path.Combine(Path.Combine(Root, "AgentScripts"), "WL813q_PROBE.txt"); + File.WriteAllText(path, _o.ToString(), new UTF8Encoding(false)); + return _o.ToString() + "\n(파일: " + path + ")"; + } + + // ─────────────────────────────────────────────── ⓪ 에셋 · 계약 + static void Step0_Asset() + { + H("⓪ 에셋 (WLRunUiSettings · NEW)"); + WLRunUiSettings.ClearCache(); + var s = WLRunUiSettings.Instance; + Chk(s != null, "Resources.Load(\"" + WLRunUiSettings.ResourcesPath + "\")"); + if (s == null) return; + Chk(WLRunUiSettings.Enabled, "Enabled(에셋·enabled_·RuntimeDisabled) = " + WLRunUiSettings.Enabled); + N("값: enabled_=" + s.enabled_ + " hud=" + s.runHudEnabled + " result=" + s.resultEnabled + + " topMargin=" + s.runHudTopMarginPx + "px width=" + s.runHudWidthPx + "px timerPx=" + s.timerFontPx + + " warn=" + s.timerWarnSeconds + "s pulse=" + s.zonePulseScale + "×" + s.zonePulseSeconds + "s"); + N("문구: win=\"" + s.resultWinTitle + "\" timeout=\"" + s.resultTimeoutTitle + "\" next=\"" + s.resultNextRunText + + "\" close=\"" + s.resultCloseText + "\" zone=\"" + s.phaseZoneFormat + "\" boss=\"" + s.phaseBossText + "\""); + N("행 순서: " + string.Join(" · ", Array.ConvertAll(s.resultRows, r => r.ToString()))); + Chk(s.resultRows != null && s.resultRows.Length == 9, "본문 행 9종(발주서 §1-2 순서)"); + Chk(!s.resultShowOnAbandon, "Abandon 팝업 없음(기본값)"); + Chk(s.resultDeferWhileRevive, "부활 팝업 우선(기본값)"); + + // 813p 계약 — UI 는 수치를 갖지 않는다 + var rs = WLRunSettings.Instance; + Chk(rs != null, "813p WLRunSettings 존재(런 길이·존 수의 주인)"); + if (rs != null) + N("813p 값(읽기만): runSeconds=" + rs.runSeconds + " zoneSpawnerIds=" + rs.zoneSpawnerIds.Length + + " zoneClearKills=[" + string.Join(",", Array.ConvertAll(rs.zoneClearKills, x => x.ToString())) + "]"); + Chk(WLLootSettings.GradeSlots == 9, "WLLootSettings.GradeSlots = 9 → lootByGrade 길이 10(813p 계약)"); + } + + // ─────────────────────────────────────────────── ① 노드 조립 + static GameObject Step1_Nodes() + { + H("① 노드 — NewGameUI 프리팹(저장 0) · Safe Area · 보스 바와 겹침"); + var root = PrefabUtility.LoadPrefabContents(kPrefab); + _root = root; + Chk(root != null, "LoadPrefabContents(" + kPrefab + ")"); + if (root == null) return null; + + var hud = WLVignetteUtil.FindUiPath(root.transform, "IngameUIs/WL_HUD"); + Chk(hud != null, "IngameUIs/WL_HUD 경로 실측"); + if (hud != null) + { + bool safe = hud.GetComponentInParent(true) != null || hud.GetComponent() != null; + Chk(safe, "WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안 (813y 실측 재확인)"); + } + + RunHud.Subscribe(true); + RunResultUI.Subscribe(true); + N("Bind HUD : " + RunHud.Bind(root.transform)); + N("Bind 결과 : " + RunResultUI.Bind(root.transform)); + Chk(RunHud.Bound, "RunHud 노드 생성(WL_RunHud · HideFlags.DontSave)"); + Chk(RunResultUI.Bound, "RunResultUI 노드 생성(WL_RunResult · HideFlags.DontSave)"); + Chk(RunEvents.RunStarted.Count == 2, "RunStarted 구독자 2(HUD+결과) — 실측 " + RunEvents.RunStarted.Count); + Chk(RunEvents.RunTick.Count == 1 && RunEvents.ZoneCleared.Count == 1 && + RunEvents.BossGateOpened.Count == 1 && RunEvents.BossStarted.Count == 1 && RunEvents.RunEnded.Count == 2, + "6종 구독 배선 = Tick1/Zone1/Gate1/Boss1/Ended2"); + + // 813c 보스 HP 바와 겹치지 않는가(둘 다 상단 · px 기준 산술) + var hs = WLHudLayoutSettings.Instance; + var s = WLRunUiSettings.Instance; + if (hs != null && s != null) + { + float barTop = hs.bossBarTopMarginPx; + float barBottom = barTop + hs.bossBarSizePx.y + hs.bossBarNameFontPx + hs.bossBarNameGapPx; + N("보스 바 = 상단 " + barTop + " ~ " + barBottom + " px · 런 HUD 상단 = " + s.runHudTopMarginPx + " px"); + Chk(s.runHudTopMarginPx >= barBottom, "런 HUD 가 보스 HP 바 아래(겹침 0 · 여유 " + + (s.runHudTopMarginPx - barBottom).ToString("F1") + " px)"); + } + N("HUD 배치 : " + RunHud.ApplyLayout()); + N("결과 배치: " + RunResultUI.ApplyLayout()); + return root; + } + + // ─────────────────────────────────────────────── ② HUD 시퀀스 + static void Step2_Hud() + { + H("② HUD — RunStarted → RunTick×3 → ZoneCleared×4 → BossGateOpened → BossStarted"); + var s = WLRunUiSettings.Instance; + RunHud.ResetState(); + + WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 4, "probe"); + Chk(RunHud.StartedSeen == 1, "RunStarted 수신 1"); + Chk(RunHud.ZoneCells == 4, "존 칸 4개 생성 — 실측 " + RunHud.ZoneCells); + Chk(RunHud.Visible, "HUD 표시 on"); + Chk(RunHud.TimerText == "04:00", "타이머 리셋 = 04:00(runSeconds 240 · 페이로드에서) — 실측 \"" + RunHud.TimerText + "\""); + Chk(RunHud.PhaseText == "존 1", "단계 = \"존 1\" — 실측 \"" + RunHud.PhaseText + "\""); + + // 틱 3회: 남은 200s(존0 6/12) → 남은 150s(존1 7/14) → 남은 25s(경고색) + WLRunUiProbeHooks.RaiseRunTick(40f, 200f, 0, 6, 12, 0, 4, RunPhase.Zones, 1); + N("tick1 timer=\"" + RunHud.TimerText + "\" 존0 fill=" + RunHud.ZoneFill(0).ToString("F2") + " 단계=\"" + RunHud.PhaseText + "\""); + Chk(RunHud.TimerText == "03:20", "틱1 mm:ss = 03:20"); + Chk(Mathf.Abs(RunHud.ZoneFill(0) - 0.5f) < 0.001f, "틱1 존0 게이지 = 6/12 = 0.50 — 실측 " + RunHud.ZoneFill(0).ToString("F3")); + + WLRunUiProbeHooks.RaiseRunTick(90f, 150f, 1, 7, 14, 1, 4, RunPhase.Zones, 1); + Chk(RunHud.TimerText == "02:30", "틱2 mm:ss = 02:30"); + Chk(Mathf.Abs(RunHud.ZoneFill(0) - 1f) < 0.001f && Mathf.Abs(RunHud.ZoneFill(1) - 0.5f) < 0.001f, + "틱2 존0=1.00(클리어) · 존1=0.50 — 실측 " + RunHud.ZoneFill(0).ToString("F2") + "/" + RunHud.ZoneFill(1).ToString("F2")); + Chk(RunHud.PhaseText == "존 2", "틱2 단계 = \"존 2\" — 실측 \"" + RunHud.PhaseText + "\""); + + WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 2, 3, 12, 2, 4, RunPhase.Zones, 1); + Chk(RunHud.TimerText == "00:25", "틱3 mm:ss = 00:25"); + var timerColor = FindTimerColor(); + Chk(s != null && ApproxColor(timerColor, s.timerWarnColor), + "마지막 " + (s != null ? s.timerWarnSeconds : 0f) + "s 경고색 #" + + (s != null ? ColorUtility.ToHtmlStringRGB(s.timerWarnColor) : "?") + " — 실측 #" + ColorUtility.ToHtmlStringRGB(timerColor)); + + // 존 클리어 4회 + 펄스 + float now = Time.unscaledTime; + for (int i = 0; i < 4; i++) + WLRunUiProbeHooks.RaiseZoneCleared(i, 813001 + i, 30f + i * 20f, 12, 12, i == 3); + Chk(RunHud.ZoneClearedSeen == 4, "ZoneCleared 4회 수신"); + bool allFull = true; + for (int i = 0; i < 4; i++) if (RunHud.ZoneFill(i) < 0.999f) allFull = false; + Chk(allFull, "존 칸 4개 전부 채움"); + RunHud.Tick(now); + float peak = RunHud.ZoneScale(3); + RunHud.Tick(now + (s != null ? s.zonePulseSeconds : 0.3f)); + float back = RunHud.ZoneScale(3); + N("펄스 실측: 시작 scale=" + peak.ToString("F3") + " → " + (s != null ? s.zonePulseSeconds : 0.3f) + "s 뒤 " + back.ToString("F3")); + Chk(peak > 1.001f && Mathf.Abs(back - 1f) < 0.001f, + "존 클리어 펄스 " + (s != null ? s.zonePulseScale : 0f) + "× → " + (s != null ? s.zonePulseSeconds : 0f) + "s 안에 1.0 복귀"); + + WLRunUiProbeHooks.RaiseBossGateOpened(150f, RunGateReason.TimeLimit, 813010, true); + Chk(RunHud.GateSeen == 1 && RunHud.PhaseText == (s != null ? s.phaseBossText : "BOSS"), + "게이트 개방 → 단계 라벨 \"" + RunHud.PhaseText + "\""); + var phaseColor = FindPhaseColor(); + Chk(s != null && ApproxColor(phaseColor, s.phaseBossColor), "보스 단계 색 #" + ColorUtility.ToHtmlStringRGB(phaseColor)); + + WLRunUiProbeHooks.RaiseBossStarted(155f, 813010); + Chk(RunHud.BossSeen == 1, "BossStarted 수신 1 (boss=null 계약 경로 · 예외 0)"); + N(RunHud.Dump().TrimEnd()); + } + + static Color FindTimerColor() + { + var t = FindLabel("Timer"); + return t != null ? t.color : Color.magenta; + } + static Color FindPhaseColor() + { + var t = FindLabel("Phase"); + return t != null ? t.color : Color.magenta; + } + static TMP_Text FindLabel(string name) + { + if (_root == null) return null; + var all = _root.GetComponentsInChildren(true); + for (int i = 0; i < all.Length; i++) + if (all[i] != null && all[i].name == name && all[i].transform.parent != null && + all[i].transform.parent.name == "WL_RunHud") return all[i]; + return null; + } + static bool ApproxColor(Color a, Color b) + { + return Mathf.Abs(a.r - b.r) < 0.01f && Mathf.Abs(a.g - b.g) < 0.01f && Mathf.Abs(a.b - b.b) < 0.01f; + } + + // ─────────────────────────────────────────────── ③ 결과 Win + static float[] _srcZoneSec; + static int[] _srcZoneKills; + static int[] _srcLoot; + + static RunResult MakeResult(RunOutcome outcome) + { + // 813p 처럼 **같은 배열을 재사용**한다(참조 보관 금지 계약을 그대로 재현) + if (_srcZoneSec == null) + { + _srcZoneSec = new float[4]; + _srcZoneKills = new int[4]; + _srcLoot = new int[10]; + } + _srcZoneSec[0] = 31.5f; _srcZoneSec[1] = 62.25f; _srcZoneSec[2] = 95f; _srcZoneSec[3] = -1f; + _srcZoneKills[0] = 12; _srcZoneKills[1] = 14; _srcZoneKills[2] = 12; _srcZoneKills[3] = 5; + for (int g = 0; g < 10; g++) _srcLoot[g] = 0; + _srcLoot[1] = 7; _srcLoot[2] = 3; _srcLoot[4] = 2; _srcLoot[9] = 1; // 등급 3·5~8 은 0 → 생략돼야 한다 + + return new RunResult + { + outcome = outcome, + runIndex = 2, + totalSec = 154f, // 02:34 + runSeconds = 240f, + zoneClearSec = _srcZoneSec, + zoneKills = _srcZoneKills, + zoneCount = 4, + zonesCleared = 3, + bossGateSec = 150f, + bossStartSec = 152f, + bossClearSec = outcome == RunOutcome.Win ? 153.5f : -1f, + kills = 99, + bossKills = outcome == RunOutcome.Win ? 1 : 0, + maxChain = 9, + maxDamage = 1234.5, + maxDamageBurst = 8888.5, + levelFrom = 11, + levelTo = 14, + lootByGrade = _srcLoot, + lootTotal = 13, + gold = 777, + deaths = 2, + potionsUsed = 3, + skillCasts = 7, + time = Time.unscaledTime, + frame = Time.frameCount, + }; + } + + static void Step3_ResultWin() + { + H("③ 결과 화면 — RunEnded(Win)"); + var s = WLRunUiSettings.Instance; + RunResultUI.ResetState(); + var r = MakeResult(RunOutcome.Win); + WLRunUiProbeHooks.RaiseRunEnded(in r); + + Chk(RunResultUI.EndedSeen == 1, "RunEnded 수신 1"); + Chk(RunResultUI.IsOpen, "결과 화면 표시(부활 팝업 없음)"); + Chk(RunResultUI.TitleText == (s != null ? s.resultWinTitle : ""), + "제목 = \"" + (s != null ? s.resultWinTitle : "") + "\" — 실측 \"" + RunResultUI.TitleText + "\""); + Chk(!RunHud.Visible, "런 종료 → HUD 숨김(runHudHideOnEnd)"); + + var c = RunResultUI.Copied; + Chk(c.outcome == RunOutcome.Win && c.runIndex == 2 && Mathf.Approximately(c.totalSec, 154f) && + c.kills == 99 && c.bossKills == 1 && c.maxChain == 9 && c.levelFrom == 11 && c.levelTo == 14 && + c.gold == 777 && c.deaths == 2 && c.potionsUsed == 3 && c.zonesCleared == 3, + "복사본 스칼라 = 입력과 일치(outcome/runIndex/total/kills/boss/chain/level/gold/deaths/potions/zones)"); + + N("행 값 실측:"); + N(" 총 시간 = \"" + RunResultUI.RowValueText(WLRunResultRow.TotalTime) + "\""); + N(" 보스 처치 = \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\""); + N(" 처치 수 = \"" + RunResultUI.RowValueText(WLRunResultRow.Kills) + "\""); + N(" 최대 연쇄 = \"" + RunResultUI.RowValueText(WLRunResultRow.MaxChain) + "\""); + N(" 최대 피해 = \"" + RunResultUI.RowValueText(WLRunResultRow.MaxDamage) + "\""); + N(" 레벨 = \"" + RunResultUI.RowValueText(WLRunResultRow.Level) + "\""); + N(" 등급별 = \"" + RunResultUI.RowValueText(WLRunResultRow.LootByGrade) + "\""); + N(" 골드 = \"" + RunResultUI.RowValueText(WLRunResultRow.Gold) + "\""); + N(" 사망/물약 = \"" + RunResultUI.RowValueText(WLRunResultRow.DeathsPotions) + "\""); + + Chk(RunResultUI.RowValueText(WLRunResultRow.TotalTime) == "02:34", "총 시간 154s → 02:34"); + Chk(RunResultUI.RowValueText(WLRunResultRow.BossClear) == "02:34", "보스 처치 153.5s → 02:34(올림)"); + Chk(RunResultUI.RowValueText(WLRunResultRow.Kills) == "99 (보스 1)", "처치 수 = 99 (보스 1)"); + Chk(RunResultUI.RowValueText(WLRunResultRow.MaxChain) == "9", "최대 연쇄 = 9"); + Chk(RunResultUI.RowValueText(WLRunResultRow.MaxDamage) == "8889", + "최대 피해 = 합산(maxDamageBurst 8888.5 → 8889 · 0.5 는 올림) · 단일(1234.5)이 아니다 — 실측 \"" + + RunResultUI.RowValueText(WLRunResultRow.MaxDamage) + "\""); + Chk(RunResultUI.RowValueText(WLRunResultRow.Level) == "11 → 14", "레벨 11 → 14"); + Chk(RunResultUI.RowValueText(WLRunResultRow.Gold) == "777", "골드 = 777"); + Chk(RunResultUI.RowValueText(WLRunResultRow.DeathsPotions) == "2 / 3", "사망/물약 = 2 / 3"); + + string loot = RunResultUI.RowValueText(WLRunResultRow.LootByGrade); + Chk(loot.Contains(">7") && loot.Contains(">3") && loot.Contains(">2") && loot.Contains(">1"), + "등급별 획득 = 1급 7 · 2급 3 · 4급 2 · 9급 1 (0 인 등급 생략 · 4칸)"); + int colorTags = 0; + for (int i = 0; i + 6 < loot.Length; i++) if (loot.Substring(i, 7) == "(true) : new ReviveDialog[0]; + Chk(dlg != null && dlg.Length > 0, "프리팹에서 ReviveDialog 발견 — " + (dlg != null ? dlg.Length : 0) + "개"); + if (dlg == null || dlg.Length == 0) return; + var d = dlg[0]; + N("ReviveDialog.Initialize : " + d.Initialize()); + N("ReviveDialog.ShowNowOnce: " + d.ShowNowOnce()); + Chk(ReviveDialog.Busy, "부활 팝업 Busy = " + ReviveDialog.Busy); + + RunResultUI.Hide(); + int deferBefore = RunResultUI.DeferredCount; + var r = MakeResult(RunOutcome.Win); + WLRunUiProbeHooks.RaiseRunEnded(in r); + Chk(!RunResultUI.IsOpen, "결과 화면 아직 안 뜬다(부활 우선)"); + Chk(RunResultUI.Pending, "결과 대기 중 = " + RunResultUI.Pending); + Chk(RunResultUI.DeferredCount > deferBefore, "미룸 카운터 +" + (RunResultUI.DeferredCount - deferBefore)); + + d.HideNow(); + Chk(!ReviveDialog.Busy, "부활 팝업 닫힘 → Busy=false"); + N("Tick: " + RunResultUI.Tick(Time.unscaledTime)); + Chk(RunResultUI.IsOpen, "부활 팝업이 닫힌 뒤 결과 화면 표시"); + Chk(!RunResultUI.Pending, "대기 해제"); + + // [다음 런] · [닫기] + int nextBefore = RunResultUI.NextRunCount; + N("[다음 런] : " + RunResultUI.OnNextRun()); + Chk(RunResultUI.NextRunCount == nextBefore + 1, "[다음 런] 클릭 경로 = RunDirector.Restart() 1회"); + Chk(!RunResultUI.IsOpen, "[다음 런] 뒤 결과 화면 닫힘"); + N("RunDirector 상태: phase=" + RunDirector.Phase + " runIndex=" + RunDirector.RunIndex + + " restartCount=" + RunDirector.RestartCount); + RunResultUI.Show(); + N("[닫기] : " + RunResultUI.OnClose()); + Chk(!RunResultUI.IsOpen, "[닫기] 뒤 결과 화면 닫힘"); + } + + // ─────────────────────────────────────────────── ⑧ GC + static void Step8_Gc() + { + H("⑧ GC — 틱 100회 할당 0 (대조군 2종)"); + // 계기: GC.GetAllocatedBytesForCurrentThread() = 이 스레드가 지금까지 할당한 누적 바이트(정밀). + // GC.GetTotalMemory 는 힙 페이지 단위라 이 규모에서 감도가 없다(대조군 B 로 실측 확인). + N("계기 = GC.GetTotalMemory (Boehm 힙 크기) + GC.CollectionCount(0) · " + + "GetAllocatedBytesForCurrentThread 절대값=" + GC.GetAllocatedBytesForCurrentThread() + + " (0 이면 이 런타임에 미구현 → 쓰지 않는다)"); + + // 본시험: 같은 초를 100번 틱한다 → 표시값이 안 바뀌므로 SetText 0회 + RunHud.SetVisible(true); + WLRunUiProbeHooks.RaiseRunTick(40f, 200f, 0, 6, 12, 0, 4, RunPhase.Zones, 1); // 준비(첫 쓰기) + int wBefore = RunHud.TimerWrites + RunHud.PhaseWrites + RunHud.FillWrites; + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long n0 = GC.GetAllocatedBytesForCurrentThread(); + long m0 = GC.GetTotalMemory(true); int g0 = GC.CollectionCount(0); + for (int i = 0; i < 100; i++) + WLRunUiProbeHooks.RaiseRunTick(40f, 200f, 0, 6, 12, 0, 4, RunPhase.Zones, 1); + long n1 = GC.GetAllocatedBytesForCurrentThread(); + long m1 = GC.GetTotalMemory(false); int g1 = GC.CollectionCount(0); + int wAfter = RunHud.TimerWrites + RunHud.PhaseWrites + RunHud.FillWrites; + N("본시험(같은 초 100틱): ΔGetTotalMemory=" + (m1 - m0) + " B · gc0 " + g0 + "→" + g1 + + " · 표시 쓰기 " + wBefore + "→" + wAfter + " (Δ할당 계기 참고값 " + (n1 - n0) + " B · 미구현)"); + Chk(wAfter == wBefore, "🔴 구조 증명: 값이 그대로면 SetText/fillAmount 쓰기 0회 = 만들 문자열 자체가 없다"); + Chk(m1 - m0 == 0 && g1 == g0, "ΔGC = 0 B · gc0 불변 (RunTick 100회)"); + + // 대조군 A: 초가 매번 바뀌는 100틱 — 값이 바뀌므로 SetText 를 100번 부른다 + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long a0 = GC.GetTotalMemory(true); int ag0 = GC.CollectionCount(0); + int wa0 = RunHud.TimerWrites; + for (int i = 0; i < 100; i++) + WLRunUiProbeHooks.RaiseRunTick(i, 200f - i, 0, 6, 12, 0, 4, RunPhase.Zones, 1); + long a1 = GC.GetTotalMemory(false); + N("대조군 A(초가 바뀌는 100틱 · SetText " + (RunHud.TimerWrites - wa0) + "회): ΔGetTotalMemory=" + (a1 - a0) + + " B · gc0 " + ag0 + "→" + GC.CollectionCount(0) + + " — 우리 코드의 mm:ss 는 char 버퍼(문자열 0)이고, 에디터에서는 TMP 가 " + + "`#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로 인스펙터용 문자열을 만든다(빌드에는 없는 줄)."); + + // 대조군 B: 측정 감도(813p·813i 선례) + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long bt0 = GC.GetTotalMemory(true); int bg0 = GC.CollectionCount(0); + var sink = new List(200); + for (int i = 0; i < 200; i++) sink.Add(new string((char)('a' + (i % 26)), 40000)); // ≈ 16 MB + long bt1 = GC.GetTotalMemory(false); int bg1 = GC.CollectionCount(0); + N("대조군 B(40,000자 문자열 200개 ≈ 16 MB): ΔGetTotalMemory=" + (bt1 - bt0) + " B · gc0 " + bg0 + "→" + bg1 + + " (sink=" + sink.Count + ") — 계기 감도 확인. 100 KB 규모는 Boehm 힙 여유분에 묻혀 Δ0 이 나온다(실측)."); + Chk(bt1 - bt0 > 0, "측정 감도 있음(대조군 B ΔGetTotalMemory > 0)"); + sink.Clear(); + + // mm:ss 포맷 자체 검증 + var buf = new char[8]; + Chk(new string(buf, 0, RunHud.FormatMMSS(buf, 0f)) == "00:00" && + new string(buf, 0, RunHud.FormatMMSS(buf, -5f)) == "00:00" && + new string(buf, 0, RunHud.FormatMMSS(buf, 59.4f)) == "01:00" && + new string(buf, 0, RunHud.FormatMMSS(buf, 240f)) == "04:00" && + new string(buf, 0, RunHud.FormatMMSS(buf, 6000f)) == "99:59", + "mm:ss 경계값(0 · 음수 · 올림 · 240 · 상한 99:59)"); + } + + // ─────────────────────────────────────────────── ⑨ C8 + static void Step9_C8() + { + H("⑨ C8 롤백 — enabled = 0 이면 HUD·결과 0 · 813p 는 그대로"); + var s = WLRunUiSettings.Instance; + bool runBefore = WLRunSettings.Enabled; + + WLRunUiSettings.RuntimeDisabled = true; + Chk(!WLRunUiSettings.Enabled, "RuntimeDisabled=1 → Enabled=False"); + N("HUD Tick: " + RunHud.Tick(Time.unscaledTime)); + N("결과 Tick: " + RunResultUI.Tick(Time.unscaledTime)); + Chk(!RunHud.Visible && !RunResultUI.IsOpen, "HUD 숨김 · 결과 닫힘"); + N("Bind(off): " + RunHud.Bind(null)); + WLRunUiSettings.RuntimeDisabled = false; + + // 에셋 필드 자체(enabled_)도 같은 게이트를 탄다 — 메모리에서만 껐다가 즉시 되돌린다(저장 0) + if (s != null) + { + bool saved = s.enabled_; + s.enabled_ = false; + Chk(!WLRunUiSettings.Enabled, "asset.enabled_=0 → Enabled=False (같은 게이트)"); + s.enabled_ = saved; + Chk(WLRunUiSettings.Enabled, "복구 후 Enabled=True"); + } + Chk(WLRunSettings.Enabled == runBefore, "813p WLRunSettings.Enabled 불변 = " + WLRunSettings.Enabled + " (런 구조는 그대로 동작)"); + } + + // ─────────────────────────────────────────────── ⑩ 정리 + static void Step10_Cleanup(GameObject prefabRoot) + { + H("⑩ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0"); + try + { + N("RunHud : " + RunHud.Teardown()); + N("RunResultUI: " + RunResultUI.Teardown()); + bool zero = RunEvents.RunStarted.Count == 0 && RunEvents.RunTick.Count == 0 && + RunEvents.ZoneCleared.Count == 0 && RunEvents.BossGateOpened.Count == 0 && + RunEvents.BossStarted.Count == 0 && RunEvents.RunEnded.Count == 0; + Chk(zero, "RunEvents 구독자 전부 0 (Started " + RunEvents.RunStarted.Count + " Tick " + RunEvents.RunTick.Count + + " Zone " + RunEvents.ZoneCleared.Count + " Gate " + RunEvents.BossGateOpened.Count + + " Boss " + RunEvents.BossStarted.Count + " Ended " + RunEvents.RunEnded.Count + ")"); + N("RunEvents.TotalRaised=" + RunEvents.TotalRaised + " last=" + RunEvents.LastEvent + + " · Dispatched(Tick)=" + RunEvents.RunTick.Dispatched); + Chk(Mathf.Approximately(Time.timeScale, 1f), "TimeScale 무접촉 = " + Time.timeScale); + } + catch (Exception e) { _o.AppendLine(" [FAIL] 정리 예외 " + e.GetType().Name + " : " + e.Message); _fail++; } + if (prefabRoot != null) + { + PrefabUtility.UnloadPrefabContents(prefabRoot); // 저장 인자 없음 = 저장하지 않는다 + _root = null; + N("UnloadPrefabContents — 프리팹 저장 0"); + } + } +} diff --git a/Assets/WL/UI/Scripts/ReviveDialog.cs b/Assets/WL/UI/Scripts/ReviveDialog.cs index 38c907e9d..914f93d12 100644 --- a/Assets/WL/UI/Scripts/ReviveDialog.cs +++ b/Assets/WL/UI/Scripts/ReviveDialog.cs @@ -44,6 +44,12 @@ namespace WL.UI /// 현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다). public static ReviveDialog Active { get; private set; } + /// + /// 부활 팝업이 지금 떠 있거나 표시 예약 중인가(공용 Popup 경로 포함). + /// WL-813q 결과 화면이 「부활 팝업 우선」 순서를 지킬 때 본다(읽기 전용 · 동작 변경 0). + /// + public static bool Busy { get { return Active != null && (Active._open || Active._showAt >= 0f); } } + /// 813i DeathFlow 가 사망 연출 뒤 부르는 진입점. 인스턴스가 없으면 무동작. public static string NotifyDeath() { diff --git a/Assets/WL/UI/Scripts/RunHud.cs b/Assets/WL/UI/Scripts/RunHud.cs new file mode 100644 index 000000000..036d8c9d9 --- /dev/null +++ b/Assets/WL/UI/Scripts/RunHud.cs @@ -0,0 +1,635 @@ +// ───────────────────────────────────────────────────────────────────────────── +// RunHud.cs — 런 HUD: 남은 시간(mm:ss) · 존 게이지 · 단계 라벨 (WL-813q · #813) +// +// 기준서 §D-2 813q 행 · 발주서 WL-813q §1-1. +// 구독 계약 = 813p 완료보고 §4 표 = `Assets/WL/Combat/Run/RunEvents.cs`(**읽기만** · 수정 0). +// RunStarted / RunTick / ZoneCleared / BossGateOpened / BossStarted / RunEnded +// +// ■ 프리팹 diff 0 (813y 방식) +// 노드를 프리팹에 굽지 않는다 — 러너도 HUD 노드도 **런타임 생성**(`HideFlags.DontSave`). +// 부모는 `IngameUIs/WL_HUD`(813y 실측 SafeAreaFitter 아래 = Safe Area 안 · 경로는 SO). +// 보스 HP 바(813c · 상단 여백 190 px + 높이 66 px = 256 px)와 겹치지 않게 **그 아래**에 앉는다 +// (기본 `runHudTopMarginPx` = 272 px · 값은 SO). +// 전투 중 하단 메뉴 숨김(813y `HudCombatVisibility`)은 화면 **아래**를 만지므로 겹치지 않는다. +// +// ■ GC 0 (틱마다 문자열 0) +// · mm:ss = char 버퍼 5글자를 직접 써서 `TMP_Text.SetText(char[], start, length)`. +// · 그마저도 **표시값이 바뀐 초에만** 부른다(같은 초를 100번 틱해도 SetText 0회). +// · 존 게이지 = `Image.fillAmount`(값이 바뀔 때만) · 펄스 = `localScale`. +// · 단계 라벨 = 단계/존 번호가 바뀔 때만 `SetText(format, arg0)`(TMP 무할당 오버로드). +// · 로그 문자열은 **만들기 전에** `verboseLog` 로 막는다(811b FIX-3 규약). +// 🔴 에디터에서는 TMP 자체가 `#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로 +// SetText 1회당 문자열 1개를 만든다(TMP_Text.cs). 이는 **TMP 의 에디터 전용 줄**이고 +// 빌드에는 없다 — 프로브가 「같은 초 100틱 = 0 B」와 「변하는 초 100틱」을 나눠 잰다. +// +// ■ C8 롤백 — `WLRunUiSettings.enabled_ = 0`(또는 `runHudEnabled = 0`) → 구독 0 · 노드 0 · 표시 0. +// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Text; +using TMPro; +using UnityEngine; +using UnityEngine.UI; +using WL.Combat.Run; + +namespace WL.UI +{ + /// 런 진행 HUD(정적 · 노드는 런타임 생성 · 프리팹 diff 0). + public static class RunHud + { + // ── 노드 ────────────────────────────────────────────────────────────── + private static Transform s_uiRoot; + private static RectTransform s_root; + private static TextMeshProUGUI s_timer; + private static TextMeshProUGUI s_phase; + private static RectTransform s_zoneRoot; + private static Image[] s_zoneBg = new Image[0]; + private static Image[] s_zoneFill = new Image[0]; + private static RectTransform[] s_zoneCell = new RectTransform[0]; + private static float[] s_zonePulseAt = new float[0]; + private static float[] s_zoneFillValue = new float[0]; + private static int s_zoneCells; + private static TMP_FontAsset s_font; + + // ── 상태(값 캐시 = 변할 때만 그린다 = GC 0) ─────────────────────────── + private static bool s_subscribed; + private static bool s_visible; + private static int s_shownSec = int.MinValue; + private static bool s_shownWarn; + private static int s_shownPhaseKind = -1; // 0 = 존 · 1 = 보스 + private static int s_shownPhaseZone = -1; + private static float s_runSeconds; + private static int s_zoneCount; + private static float s_remaining; + private static bool s_bossPhase; + + // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── + public static int StartedSeen, TickSeen, ZoneClearedSeen, GateSeen, BossSeen, EndedSeen; + public static int TimerWrites, PhaseWrites, FillWrites; + public static string LastEdge = ""; + 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; } } + public static int ZoneCells { get { return s_zoneCells; } } + /// 지금 화면에 떠 있는 타이머 문자열(실측 덤프용 — 표시 경로가 아니다). + public static string TimerText { get { return s_timer != null ? s_timer.text : ""; } } + public static string PhaseText { get { return s_phase != null ? s_phase.text : ""; } } + + private static WLRunUiSettings St { get { return WLRunUiSettings.Instance; } } + + // ── 부팅 (노드 0 · 프리팹 diff 0) ───────────────────────────────────── + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void Boot() + { + var s = St; + if (!WLRunUiSettings.Enabled || s == null || !s.runHudEnabled) return; + Subscribe(true); + WLRunUiRunner.Ensure(); + } + + // ── 구독 (813p RunEvents 6종) ───────────────────────────────────────── + public static string Subscribe(bool on) + { + if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; + if (on) + { + RunEvents.RunStarted.Add(OnRunStarted); + RunEvents.RunTick.Add(OnRunTick); + RunEvents.ZoneCleared.Add(OnZoneCleared); + RunEvents.BossGateOpened.Add(OnBossGateOpened); + RunEvents.BossStarted.Add(OnBossStarted); + RunEvents.RunEnded.Add(OnRunEnded); + } + else + { + RunEvents.RunStarted.Remove(OnRunStarted); + RunEvents.RunTick.Remove(OnRunTick); + RunEvents.ZoneCleared.Remove(OnZoneCleared); + RunEvents.BossGateOpened.Remove(OnBossGateOpened); + RunEvents.BossStarted.Remove(OnBossStarted); + RunEvents.RunEnded.Remove(OnRunEnded); + } + s_subscribed = on; + return "구독=" + on + " (RunStarted/RunTick/ZoneCleared/BossGateOpened/BossStarted/RunEnded)"; + } + + // ── 이벤트 핸들러 (in 파라미터 = 복사 0 · 813p 계약) ────────────────── + private static void OnRunStarted(in RunStartedEvent e) + { + StartedSeen++; + s_runSeconds = e.runSeconds; + s_zoneCount = e.zoneCount; + s_remaining = e.runSeconds; + s_bossPhase = false; + s_shownSec = int.MinValue; s_shownWarn = false; + s_shownPhaseKind = -1; s_shownPhaseZone = -1; + BuildZoneCells(e.zoneCount); + ResetZoneCells(); + SetVisible(true); + WriteTimer(e.runSeconds); + WritePhase(false, 0); + var s = St; + if (s != null && s.verboseLog) Debug.Log("[RunHud] 런 시작 #" + e.runIndex + " — " + e.runSeconds + "s · 존 " + e.zoneCount); + } + + private static void OnRunTick(in RunTickEvent e) + { + TickSeen++; + s_remaining = e.remainingSec; + if (e.zoneCount != s_zoneCount) { s_zoneCount = e.zoneCount; BuildZoneCells(e.zoneCount); } + WriteTimer(e.remainingSec); + // 클리어한 칸은 가득 · 현재 칸은 진행률 · 나머지는 0 (전부 변할 때만 쓴다) + for (int i = 0; i < s_zoneCells; i++) + { + float v = i < e.zonesCleared ? 1f + : (i == e.zoneIndex && e.zoneRequired > 0 ? Mathf.Clamp01((float)e.zoneKills / e.zoneRequired) : 0f); + SetFill(i, v, i < e.zonesCleared); + } + bool boss = s_bossPhase || e.phase == RunPhase.BossGate || e.phase == RunPhase.Boss; + WritePhase(boss, e.zoneIndex); + } + + private static void OnZoneCleared(in ZoneClearedEvent e) + { + ZoneClearedSeen++; + if (e.zoneIndex >= 0 && e.zoneIndex < s_zoneCells) + { + SetFill(e.zoneIndex, 1f, true); + s_zonePulseAt[e.zoneIndex] = Time.unscaledTime; // 펄스 시작(러너가 되돌린다) + } + var s = St; + if (s != null && s.verboseLog) Debug.Log("[RunHud] 존 클리어 #" + e.zoneIndex + " " + e.kills + "/" + e.required); + } + + private static void OnBossGateOpened(in BossGateOpenedEvent e) + { + GateSeen++; + s_bossPhase = true; + WritePhase(true, 0); + } + + private static void OnBossStarted(in BossStartedEvent e) + { + BossSeen++; + s_bossPhase = true; // e.boss 는 null 일 수 있다(813p 계약) — 여기서는 쓰지 않는다 + WritePhase(true, 0); + } + + private static void OnRunEnded(in RunResult e) + { + EndedSeen++; + s_bossPhase = false; + var s = St; + if (s != null && s.runHudHideOnEnd) SetVisible(false); + } + + // ── 표시 (전부 "변할 때만" 쓴다) ────────────────────────────────────── + private static readonly char[] s_timeBuf = new char[8]; + + /// 남은 초 → "mm:ss" 를 char 버퍼에 직접 쓴다(문자열 0). 글자 수를 돌려준다. + public static int FormatMMSS(char[] buf, float seconds) + { + if (buf == null || buf.Length < 5) return 0; + int t = seconds <= 0f ? 0 : Mathf.CeilToInt(seconds); + int m = t / 60; + int sec = t - m * 60; + if (m > 99) { m = 99; sec = 59; } + buf[0] = (char)('0' + m / 10); + buf[1] = (char)('0' + m % 10); + buf[2] = ':'; + buf[3] = (char)('0' + sec / 10); + buf[4] = (char)('0' + sec % 10); + return 5; + } + + private static void WriteTimer(float remaining) + { + var s = St; + 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; + 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; } + int n = FormatMMSS(s_timeBuf, remaining); + s_timer.SetText(s_timeBuf, 0, n); + TimerWrites++; + } + + private static void WritePhase(bool boss, int zoneIndex) + { + var s = St; + if (s_phase == null || s == null || !s.phaseLabelEnabled) return; + int kind = boss ? 1 : 0; + int zone = boss ? -1 : zoneIndex; + if (kind == s_shownPhaseKind && zone == s_shownPhaseZone) return; + s_shownPhaseKind = kind; s_shownPhaseZone = zone; + if (boss) + { + s_phase.SetText(s.phaseBossText); + s_phase.color = s.phaseBossColor; + } + else + { + int shown = Mathf.Clamp(zoneIndex + 1, 1, Mathf.Max(1, s_zoneCount)); + s_phase.SetText(s.phaseZoneFormat, shown); // TMP 무할당 오버로드({0} = float) + s_phase.color = s.phaseColor; + } + PhaseWrites++; + } + + private static void SetFill(int i, float v, bool cleared) + { + if (i < 0 || i >= s_zoneCells || s_zoneFill[i] == null) return; + var s = St; + if (Mathf.Abs(v - s_zoneFillValue[i]) < 0.0005f) return; // 값이 그대로면 아무 것도 하지 않는다 + s_zoneFillValue[i] = v; + s_zoneFill[i].fillAmount = v; + if (s != null) + { + var want = cleared || v >= 0.999f ? s.zoneCellClearColor : s.zoneCellFillColor; + if (s_zoneFill[i].color != want) s_zoneFill[i].color = want; + } + FillWrites++; + } + + private static void ResetZoneCells() + { + for (int i = 0; i < s_zoneCells; i++) + { + s_zoneFillValue[i] = -1f; // 다음 SetFill 이 반드시 쓰게 한다 + SetFill(i, 0f, false); + s_zonePulseAt[i] = -9999f; + if (s_zoneCell[i] != null) s_zoneCell[i].localScale = Vector3.one; + } + } + + public static void SetVisible(bool on) + { + s_visible = on; + if (s_root != null && s_root.gameObject.activeSelf != on) s_root.gameObject.SetActive(on); + } + + // ── 매 프레임(러너) — 펄스만 돌린다 ─────────────────────────────────── + public static string Tick(float now) + { + var s = St; + if (s == null) return "WLRunUiSettings 에셋 없음"; + if (!WLRunUiSettings.Enabled || !s.runHudEnabled) { if (s_visible) SetVisible(false); return "off — 무동작"; } + float dur = Mathf.Max(0f, s.zonePulseSeconds); + float peak = Mathf.Max(1f, s.zonePulseScale); + for (int i = 0; i < s_zoneCells; i++) + { + var rt = s_zoneCell[i]; + if (rt == null) continue; + float k = dur <= 0f ? 1f : Mathf.Clamp01((now - s_zonePulseAt[i]) / dur); + float sc = k >= 1f ? 1f : Mathf.Lerp(peak, 1f, k * k); + if (!Mathf.Approximately(rt.localScale.x, sc)) rt.localScale = new Vector3(sc, sc, 1f); + } + return "tick ok"; + } + + // ── 조립 (런타임 생성 · 프리팹 diff 0) ──────────────────────────────── + /// UI 루트(NewGameUI)에서 부모를 찾아 HUD 노드를 만든다. 이미 물려 있으면 그대로. + public static string Bind(Transform uiRoot) + { + var s = St; + if (s == null) return "WLRunUiSettings 에셋 없음"; + if (!WLRunUiSettings.Enabled || !s.runHudEnabled) return "off — 조립 생략"; + if (uiRoot == null) return "uiRoot=null"; + if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name; + + s_uiRoot = uiRoot; + var parent = WLVignetteUtil.FindUiPath(uiRoot, s.runHudParentPath); + var prt = parent as RectTransform; + if (prt == null) return "부모 없음 — 경로 \"" + s.runHudParentPath + "\""; + + if (s_font == null) s_font = BorrowFont(uiRoot); + + if (s_root == null || s_root.parent != prt) + { + var go = new GameObject("WL_RunHud", typeof(RectTransform)); + go.layer = WLTextFxUtil.UILayer; + go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다 + s_root = (RectTransform)go.transform; + s_root.SetParent(prt, false); + s_timer = WLTextFxUtil.NewText(s_root, "Timer", s_font, TextAlignmentOptions.Center); + s_zoneRoot = WLTextFxUtil.NewChild(s_root, "Zones"); + s_phase = WLTextFxUtil.NewText(s_root, "Phase", s_font, TextAlignmentOptions.Center); + s_zoneCells = 0; + } + BuildZoneCells(Mathf.Max(s_zoneCount, RunDirector.ZoneCount)); + string layout = ApplyLayout(); + SetVisible(RunDirector.IsRunning); + return "연결 " + s.runHudParentPath + " · " + layout; + } + + /// 기존 HUD 라벨에서 폰트를 빌린다(아트 에셋 참조 0 · 한글 글리프 확보). + public static TMP_FontAsset BorrowFont(Transform uiRoot) + { + if (uiRoot == null) return null; + var labels = uiRoot.GetComponentsInChildren(true); + for (int i = 0; i < labels.Length; i++) + if (labels[i] != null && labels[i].font != null) return labels[i].font; + return null; + } + + /// 폰트를 직접 지정한다(프로브·QA). + public static void SetFont(TMP_FontAsset f) + { + s_font = f; + if (f == null) return; + if (s_timer != null) s_timer.font = f; + if (s_phase != null) s_phase.font = f; + } + + /// 존 칸을 개수에 맞춰 만든다(개수가 그대로면 아무 것도 하지 않는다). + public static bool BuildZoneCells(int want) + { + want = Mathf.Max(0, want); + if (s_zoneRoot == null) return false; + if (want == s_zoneCells) return false; // 칸 수가 그대로면 다시 만들지 않는다 + + if (s_zoneCell.Length < want) + { + System.Array.Resize(ref s_zoneCell, want); + System.Array.Resize(ref s_zoneBg, want); + System.Array.Resize(ref s_zoneFill, want); + System.Array.Resize(ref s_zonePulseAt, want); + System.Array.Resize(ref s_zoneFillValue, want); + } + for (int i = 0; i < want; i++) + { + if (s_zoneCell[i] != null) continue; + var cell = WLTextFxUtil.NewChild(s_zoneRoot, "Zone" + i); + var bg = cell.GetComponent(); + if (bg == null) bg = cell.gameObject.AddComponent(); + bg.raycastTarget = false; + var fillRt = WLTextFxUtil.NewChild(cell, "Fill"); + WLTextFxUtil.Stretch(fillRt); + var fill = fillRt.GetComponent(); + if (fill == null) fill = fillRt.gameObject.AddComponent(); + fill.raycastTarget = false; + fill.type = Image.Type.Filled; + fill.fillMethod = Image.FillMethod.Horizontal; + fill.fillOrigin = (int)Image.OriginHorizontal.Left; + fill.fillAmount = 0f; + s_zoneCell[i] = cell; s_zoneBg[i] = bg; s_zoneFill[i] = fill; + s_zonePulseAt[i] = -9999f; s_zoneFillValue[i] = -1f; + } + // 남는 칸은 파괴하지 않고 숨긴다(다음 런에서 다시 쓴다) + for (int i = want; i < s_zoneCell.Length; i++) + if (s_zoneCell[i] != null && s_zoneCell[i].gameObject.activeSelf) s_zoneCell[i].gameObject.SetActive(false); + for (int i = 0; i < want; i++) + if (s_zoneCell[i] != null && !s_zoneCell[i].gameObject.activeSelf) s_zoneCell[i].gameObject.SetActive(true); + + s_zoneCells = want; + ApplyLayout(); + return true; + } + + /// SO 값으로 배치한다(px → 유닛 환산은 813c 산식 하나만 쓴다). + public static string ApplyLayout() + { + var s = St; + if (s == null) return "WLRunUiSettings 에셋 없음 — 배치 건너뜀"; + if (s_root == null) return "노드 없음 — Bind 먼저"; + + var canvas = s_root.GetComponentInParent(true); + float u = s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null); + + float zoneH = s.zoneGaugeEnabled && s_zoneCells > 0 ? s.zoneCellHeightPx : 0f; + float phaseH = s.phaseLabelEnabled ? s.phaseFontPx : 0f; + float totalH = s.timerFontPx + + (zoneH > 0f ? s.zoneGaugeTopGapPx + zoneH : 0f) + + (phaseH > 0f ? s.phaseTopGapPx + phaseH : 0f); + + s_root.anchorMin = s_root.anchorMax = new Vector2(0.5f, 1f); + s_root.pivot = new Vector2(0.5f, 1f); + s_root.anchoredPosition = new Vector2(s.runHudCenterOffsetXPx * u, -s.runHudTopMarginPx * u); + s_root.sizeDelta = new Vector2(s.runHudWidthPx * u, totalH * u); + s_root.localScale = Vector3.one; + + float y = 0f; + if (s_timer != null) + { + var rt = s_timer.rectTransform; + 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, y); + rt.sizeDelta = new Vector2(0f, s.timerFontPx * u); + s_timer.fontSize = s.timerFontPx * u; + s_timer.alignment = TextAlignmentOptions.Center; + s_timer.color = s_shownWarn ? s.timerWarnColor : s.timerColor; + s_timer.gameObject.SetActive(s.timerEnabled); + LastEdge = WLTextFxUtil.ApplyEdge(s_timer, s.timerEdge); + } + y -= s.timerFontPx * u; + + if (s_zoneRoot != null) + { + y -= s.zoneGaugeTopGapPx * u; + s_zoneRoot.anchorMin = new Vector2(0f, 1f); s_zoneRoot.anchorMax = new Vector2(1f, 1f); + s_zoneRoot.pivot = new Vector2(0.5f, 1f); + s_zoneRoot.offsetMin = new Vector2(0f, 0f); s_zoneRoot.offsetMax = new Vector2(0f, 0f); + s_zoneRoot.anchoredPosition = new Vector2(0f, y); + s_zoneRoot.sizeDelta = new Vector2(0f, zoneH * u); + s_zoneRoot.gameObject.SetActive(s.zoneGaugeEnabled); + + int n = Mathf.Max(1, s_zoneCells); + float totalGap = s.zoneCellGapPx * (n - 1) * u; + float cellW = (s.runHudWidthPx * u - totalGap) / n; + for (int i = 0; i < s_zoneCells; i++) + { + var cell = s_zoneCell[i]; + if (cell == null) continue; + cell.anchorMin = cell.anchorMax = new Vector2(0f, 0.5f); + cell.pivot = new Vector2(0f, 0.5f); + cell.anchoredPosition = new Vector2(i * (cellW + s.zoneCellGapPx * u), 0f); + cell.sizeDelta = new Vector2(cellW, s.zoneCellHeightPx * u); + if (s_zoneBg[i] != null) s_zoneBg[i].color = s.zoneCellBgColor; + } + y -= zoneH * u; + } + + if (s_phase != null) + { + y -= s.phaseTopGapPx * u; + var rt = s_phase.rectTransform; + 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, y); + rt.sizeDelta = new Vector2(0f, s.phaseFontPx * u); + s_phase.fontSize = s.phaseFontPx * u; + s_phase.alignment = TextAlignmentOptions.Center; + s_phase.gameObject.SetActive(s.phaseLabelEnabled); + WLTextFxUtil.ApplyEdge(s_phase, s.phaseEdge); + } + + return "pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta + + " 칸=" + s_zoneCells + " unitsPerPx=" + u.ToString("F4"); + } + + // ── 프로브 · 진단 ───────────────────────────────────────────────────── + /// 카운터·표시 상태만 초기화(노드는 남긴다). + public static string ResetState() + { + StartedSeen = TickSeen = ZoneClearedSeen = GateSeen = BossSeen = EndedSeen = 0; + TimerWrites = PhaseWrites = FillWrites = 0; + s_shownSec = int.MinValue; s_shownWarn = false; + s_shownPhaseKind = -1; s_shownPhaseZone = -1; + s_bossPhase = false; s_remaining = 0f; + ResetZoneCells(); + return "상태 초기화(칸 " + s_zoneCells + ")"; + } + + /// 노드까지 되돌린다(프로브 종료용). + 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_timer = null; s_phase = null; s_zoneRoot = null; s_uiRoot = null; + s_zoneCell = new RectTransform[0]; s_zoneBg = new Image[0]; s_zoneFill = new Image[0]; + s_zonePulseAt = new float[0]; s_zoneFillValue = new float[0]; + s_zoneCells = 0; s_visible = false; + ResetState(); + return "teardown 완료"; + } + + /// 존 칸의 현재 채움값(실측 덤프용). + public static float ZoneFill(int i) { return i >= 0 && i < s_zoneCells ? s_zoneFillValue[i] : -1f; } + /// 존 칸의 현재 펄스 스케일(실측 덤프용). + public static float ZoneScale(int i) { return i >= 0 && i < s_zoneCells && s_zoneCell[i] != null ? s_zoneCell[i].localScale.x : -1f; } + + public static string Dump() + { + var s = St; + var sb = new StringBuilder(); + sb.AppendLine("[RunHud] 구독=" + s_subscribed + " bound=" + Bound + " visible=" + s_visible + + " · started=" + StartedSeen + " tick=" + TickSeen + " zoneCleared=" + ZoneClearedSeen + + " gate=" + GateSeen + " boss=" + BossSeen + " ended=" + EndedSeen); + sb.AppendLine(" 타이머 \"" + TimerText + "\" (남은 " + s_remaining.ToString("F1") + "s · 경고=" + s_shownWarn + + " · 쓰기 " + TimerWrites + "회) · 단계 \"" + PhaseText + "\" (쓰기 " + PhaseWrites + "회)"); + sb.Append(" 존 칸 " + s_zoneCells + " (fill 쓰기 " + FillWrites + "회):"); + for (int i = 0; i < s_zoneCells; i++) + sb.Append(" [").Append(i).Append("]=").Append(ZoneFill(i).ToString("F2")) + .Append("×").Append(ZoneScale(i).ToString("F2")); + sb.AppendLine(); + if (s_root != null) + sb.AppendLine(" 노드 pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta + + " active=" + s_root.gameObject.activeSelf + " 부모=" + + (s_root.parent != null ? s_root.parent.name : "-") + + " SafeArea=" + (s_root.GetComponentInParent(true) != null)); + sb.AppendLine(" edge = " + LastEdge); + if (s != null) + sb.AppendLine(" 설정 enabled=" + s.enabled_ + " hud=" + s.runHudEnabled + " topMargin=" + s.runHudTopMarginPx + + "px width=" + s.runHudWidthPx + "px timerPx=" + s.timerFontPx + + " warn=" + s.timerWarnSeconds + "s pulse=" + s.zonePulseScale + "×" + s.zonePulseSeconds + "s"); + return sb.ToString(); + } + } + + /// + /// 🔴 **검증 전용** — 에디트 모드 프로브가 813p `RunEvents` 의 **실제 Dispatch 경로**로 + /// 가짜 이벤트를 흘려 넣는 진입점(813g/813y `LootToast.RaiseFakeKilled` 선례). + /// + /// `CombatEventList<T>.Dispatch` 는 `internal` 이라 Assets 안(같은 Assembly-CSharp)에서만 부를 수 있다. + /// 전부 **void** 다 — 반환 문자열을 만들면 GC 측정이 오염되기 때문이다(카운터는 `RunEvents.*.Dispatched`). + /// 게임 코드는 이 클래스를 한 번도 부르지 않는다. + /// + public static class WLRunUiProbeHooks + { + public static void RaiseRunStarted(int runIndex, float runSeconds, int zoneCount, string reason) + { + var e = new RunStartedEvent + { + runIndex = runIndex, startTime = Time.unscaledTime, runSeconds = runSeconds, + zoneCount = zoneCount, reason = reason, time = Time.unscaledTime, frame = Time.frameCount, + }; + RunEvents.RunStarted.Dispatch(in e); + } + + public static void RaiseRunTick(float elapsedSec, float remainingSec, int zoneIndex, int zoneKills, + int zoneRequired, int zonesCleared, int zoneCount, RunPhase phase, int runIndex) + { + var e = new RunTickEvent + { + elapsedSec = elapsedSec, remainingSec = remainingSec, zoneIndex = zoneIndex, + zoneKills = zoneKills, zoneRequired = zoneRequired, zonesCleared = zonesCleared, + zoneCount = zoneCount, phase = phase, runIndex = runIndex, + time = Time.unscaledTime, frame = Time.frameCount, + }; + RunEvents.RunTick.Dispatch(in e); + } + + public static void RaiseZoneCleared(int zoneIndex, int spawnerId, float elapsedSec, int kills, int required, bool all) + { + var e = new ZoneClearedEvent + { + zoneIndex = zoneIndex, spawnerId = spawnerId, elapsedSec = elapsedSec, + kills = kills, required = required, allZonesCleared = all, + time = Time.unscaledTime, frame = Time.frameCount, + }; + RunEvents.ZoneCleared.Dispatch(in e); + } + + public static void RaiseBossGateOpened(float elapsedSec, RunGateReason reason, int bossSpawnerId, bool armed) + { + var e = new BossGateOpenedEvent + { + elapsedSec = elapsedSec, reason = reason, bossSpawnerId = bossSpawnerId, arenaGateArmed = armed, + time = Time.unscaledTime, frame = Time.frameCount, + }; + RunEvents.BossGateOpened.Dispatch(in e); + } + + /// 보스는 일부러 null 로 둔다 — 813p 계약의 "null 가능" 경로를 그대로 태운다. + public static void RaiseBossStarted(float elapsedSec, int spawnerId) + { + var e = new BossStartedEvent + { + elapsedSec = elapsedSec, spawnerId = spawnerId, boss = null, + time = Time.unscaledTime, frame = Time.frameCount, + }; + RunEvents.BossStarted.Dispatch(in e); + } + + public static void RaiseRunEnded(in RunResult r) { RunEvents.RunEnded.Dispatch(in r); } + } + + /// 런 UI(HUD · 결과 화면)를 매 프레임 돌리는 런타임 전용 러너(씬에 굽지 않는다). + internal sealed class WLRunUiRunner : MonoBehaviour + { + private static WLRunUiRunner s_ins; + private NewGameUI _lastUi; + + internal static void Ensure() + { + if (s_ins != null) return; + var go = new GameObject("WL_RunUiRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + Object.DontDestroyOnLoad(go); + s_ins = go.AddComponent(); + } + + private void Update() + { + var ui = NewGameUI.Ins; + if (ui != null && !ReferenceEquals(ui, _lastUi)) + { + _lastUi = ui; + RunHud.Bind(ui.transform); + RunResultUI.Bind(ui.transform); + } + float now = Time.unscaledTime; + RunHud.Tick(now); + RunResultUI.Tick(now); + } + } +} diff --git a/Assets/WL/UI/Scripts/RunHud.cs.meta b/Assets/WL/UI/Scripts/RunHud.cs.meta new file mode 100644 index 000000000..2c387d2fe --- /dev/null +++ b/Assets/WL/UI/Scripts/RunHud.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1bda35cb1de246388cd62b92312a64b3 diff --git a/Assets/WL/UI/Scripts/RunResultUI.cs b/Assets/WL/UI/Scripts/RunResultUI.cs new file mode 100644 index 000000000..be8d655c9 --- /dev/null +++ b/Assets/WL/UI/Scripts/RunResultUI.cs @@ -0,0 +1,698 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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). +// +// ■ 프리팹 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.Run; + +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; + + // ── 복사 버퍼 (🔴 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 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 = ""; + 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 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); } + else { RunEvents.RunEnded.Remove(OnRunEnded); RunEvents.RunStarted.Remove(OnRunStarted); } + s_subscribed = on; + return "구독=" + on + " (RunEnded/RunStarted)"; + } + + // ── 이벤트 ──────────────────────────────────────────────────────────── + private static void OnRunStarted(in RunStartedEvent e) + { + s_pending = false; + if (s_open) Hide(); // 다음 런이 시작되면 결과 화면은 닫힌다 + } + + private static void OnRunEnded(in RunResult e) + { + EndedSeen++; + 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)]; + } + + // ── 표시 ────────────────────────────────────────────────────────────── + /// 부활 팝업이 비면 띄운다. 아직 떠 있으면 미뤄 둔다(러너가 다시 부른다). + public static string TryShow(float now) + { + var s = St; + if (s == null) return "WLRunUiSettings 에셋 없음"; + if (!s_pending) return "대기 중인 결과 없음"; + if (s.resultDeferWhileRevive && ReviveDialog.Busy) + { + DeferredCount++; + LastShowLog = "부활 팝업 우선 — 결과 화면 대기"; + return LastShowLog; + } + s_pending = false; + return Show(); + } + + /// 지금 바로 결과 화면을 띄운다(공용 팝업 스위치 → 런타임 패널). + 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 위로 + LastShowLog = "런타임 패널 표시 outcome=" + s_result.outcome + " 행=" + VisibleRows; + 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; + 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: + AppendTime(sb, s_result.bossClearSec, s); + 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; + default: + AppendFormat2(sb, s.rowDeathsFormat, s_result.deaths, s_result.potionsUsed); + break; + } + } + + /// 초 → 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); + string layout = ApplyLayout(); + Hide(); + return "연결 " + s.resultParentPath + " · " + layout; + } + + 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