Merge branch 'wl/ui/WL-813q-run-result'
This commit is contained in:
commit
3adc1bcca8
|
|
@ -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"
|
||||
등급별 = "<color=#F2F5FA>7</color> <color=#cd835e>3</color> <color=#2ca1f4>2</color> <color=#FF12A8>1</color>"
|
||||
골드 = "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=<color=#a6a6a7>→<color=#F2F5FA> 2=<color=#cd835e> 4=<color=#2ca1f4> 9=<color=#FF12A8>
|
||||
[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 : "<color=#F2F5FA>7</color> <color=#cd835e>3</color> <color=#2ca1f4>2</color> <color=#FF12A8>1</color>"
|
||||
- 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 | 획득 : <color=#F2F5FA>7</color> <color=#cd835e>3</color> <color=#2ca1f4>2</color> <color=#FF12A8>1</color> | 골드 : 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)
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
// WL-813q 프로브 — 런 HUD + 런 결과 화면 실측 (에디트 모드 · Play 0 · 로그인 0).
|
||||
// unity command run_script --file AgentScripts/WL813q_Probe.cs --entry WL813q_Probe.RunAll
|
||||
// 산출물: <worktree>/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<SafeAreaFitter>(true) != null || hud.GetComponent<SafeAreaFitter>() != 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<TextMeshProUGUI>(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) == "<color=") colorTags++;
|
||||
Chk(colorTags == 4, "등급 색 태그 4개(813f 팔레트 + 813y 밝기 하한) — 실측 " + colorTags);
|
||||
var text = WLCombatTextSettings.Instance;
|
||||
if (text != null)
|
||||
N(" 등급 색: 1=" + text.GradeColorTag(1) + "→" + text.GradeColorTagBright(1) +
|
||||
" 2=" + text.GradeColorTagBright(2) + " 4=" + text.GradeColorTagBright(4) + " 9=" + text.GradeColorTagBright(9));
|
||||
Chk(RunResultUI.VisibleRows == 9, "본문 9행 표시 — 실측 " + RunResultUI.VisibleRows);
|
||||
N(RunResultUI.Dump().TrimEnd());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────── ④ 배열 복사 증명
|
||||
static void Step4_ArrayCopy()
|
||||
{
|
||||
H("④ 🔴 배열 3종 즉시 복사(813p 재사용 계약) — 원본을 덮어써도 화면 값 불변");
|
||||
string beforeLoot = RunResultUI.RowValueText(WLRunResultRow.LootByGrade);
|
||||
float beforeZ0 = RunResultUI.CopiedZoneClearSec(0);
|
||||
int beforeK0 = RunResultUI.CopiedZoneKills(0);
|
||||
int beforeG1 = RunResultUI.CopiedLootByGrade(1);
|
||||
int growBefore = RunResultUI.BufferGrowCount;
|
||||
|
||||
// 813p RunDirector 가 다음 런에서 하는 그대로 — **같은 배열 인스턴스**를 덮어쓴다
|
||||
for (int i = 0; i < _srcZoneSec.Length; i++) { _srcZoneSec[i] = -999f; _srcZoneKills[i] = -999; }
|
||||
for (int g = 0; g < _srcLoot.Length; g++) _srcLoot[g] = 999;
|
||||
|
||||
var c = RunResultUI.Copied;
|
||||
Chk(!ReferenceEquals(c.zoneClearSec, _srcZoneSec), "zoneClearSec 참조가 원본과 다르다(자기 버퍼)");
|
||||
Chk(!ReferenceEquals(c.zoneKills, _srcZoneKills), "zoneKills 참조가 원본과 다르다(자기 버퍼)");
|
||||
Chk(!ReferenceEquals(c.lootByGrade, _srcLoot), "lootByGrade 참조가 원본과 다르다(자기 버퍼)");
|
||||
Chk(Mathf.Approximately(RunResultUI.CopiedZoneClearSec(0), beforeZ0) && RunResultUI.CopiedZoneKills(0) == beforeK0,
|
||||
"존 배열 값 불변 — clearSec[0]=" + RunResultUI.CopiedZoneClearSec(0).ToString("F2") +
|
||||
" kills[0]=" + RunResultUI.CopiedZoneKills(0) + " (원본은 −999 로 덮어씀)");
|
||||
Chk(RunResultUI.CopiedLootByGrade(1) == beforeG1, "loot 배열 값 불변 — [1]=" + RunResultUI.CopiedLootByGrade(1) + " (원본은 999)");
|
||||
Chk(RunResultUI.RowValueText(WLRunResultRow.LootByGrade) == beforeLoot, "결과 화면 등급 줄 문자열 불변");
|
||||
N(" 원본 배열 현재값: zoneClearSec[0]=" + _srcZoneSec[0] + " zoneKills[0]=" + _srcZoneKills[0] + " loot[1]=" + _srcLoot[1]);
|
||||
|
||||
// 두 번째 런: 버퍼를 다시 잡지 않는다(= 런마다 할당 0)
|
||||
var r2 = MakeResult(RunOutcome.Win);
|
||||
RunResultUI.CopyResult(in r2);
|
||||
Chk(RunResultUI.BufferGrowCount == growBefore, "두 번째 복사에서 버퍼 증설 0(런마다 배열 할당 0) — 증설 누계 " + RunResultUI.BufferGrowCount);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────── ⑤ Timeout
|
||||
static void Step5_Timeout()
|
||||
{
|
||||
H("⑤ 시간 종료(Timeout)");
|
||||
var s = WLRunUiSettings.Instance;
|
||||
RunResultUI.Hide();
|
||||
var r = MakeResult(RunOutcome.Timeout);
|
||||
WLRunUiProbeHooks.RaiseRunEnded(in r);
|
||||
Chk(RunResultUI.IsOpen, "결과 화면 표시");
|
||||
Chk(RunResultUI.TitleText == (s != null ? s.resultTimeoutTitle : ""),
|
||||
"제목 = \"" + (s != null ? s.resultTimeoutTitle : "") + "\" — 실측 \"" + RunResultUI.TitleText + "\"");
|
||||
Chk(RunResultUI.RowValueText(WLRunResultRow.BossClear) == (s != null ? s.resultDashText : "—"),
|
||||
"보스 처치 −1 → \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\"(미발생 대시)");
|
||||
Chk(RunResultUI.RowValueText(WLRunResultRow.Kills) == "99 (보스 0)", "보스 처치 0");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────── ⑥ Abandon
|
||||
static void Step6_Abandon()
|
||||
{
|
||||
H("⑥ 이탈(Abandon) — 팝업 없음 · HUD 리셋만");
|
||||
RunResultUI.Hide();
|
||||
int shownBefore = RunResultUI.ShownCount;
|
||||
int skipBefore = RunResultUI.SkipAbandonCount;
|
||||
var r = MakeResult(RunOutcome.Abandon);
|
||||
WLRunUiProbeHooks.RaiseRunEnded(in r);
|
||||
Chk(!RunResultUI.IsOpen, "팝업 0 (open=" + RunResultUI.IsOpen + ")");
|
||||
Chk(RunResultUI.ShownCount == shownBefore, "표시 호출 0 (누계 " + RunResultUI.ShownCount + ")");
|
||||
Chk(RunResultUI.SkipAbandonCount == skipBefore + 1, "Abandon 생략 카운터 +1");
|
||||
Chk(!RunResultUI.Pending, "대기(pending) 0 — 나중에 저절로 뜨지 않는다");
|
||||
Chk(!RunHud.Visible, "HUD 숨김(리셋)");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────── ⑦ 부활 팝업 우선
|
||||
static void Step7_RevivePriority()
|
||||
{
|
||||
H("⑦ 813i/813tj 부활 팝업과 동시 — 부활 우선 · 결과는 닫힌 뒤");
|
||||
var dlg = _root != null ? _root.GetComponentsInChildren<ReviveDialog>(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<string>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,12 @@ namespace WL.UI
|
|||
/// <summary>현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다).</summary>
|
||||
public static ReviveDialog Active { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 부활 팝업이 지금 떠 있거나 표시 예약 중인가(공용 Popup 경로 포함).
|
||||
/// WL-813q 결과 화면이 「부활 팝업 우선」 순서를 지킬 때 본다(읽기 전용 · 동작 변경 0).
|
||||
/// </summary>
|
||||
public static bool Busy { get { return Active != null && (Active._open || Active._showAt >= 0f); } }
|
||||
|
||||
/// <summary>813i DeathFlow 가 사망 연출 뒤 부르는 진입점. 인스턴스가 없으면 무동작.</summary>
|
||||
public static string NotifyDeath()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>런 진행 HUD(정적 · 노드는 런타임 생성 · 프리팹 diff 0).</summary>
|
||||
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; } }
|
||||
/// <summary>지금 화면에 떠 있는 타이머 문자열(실측 덤프용 — 표시 경로가 아니다).</summary>
|
||||
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];
|
||||
|
||||
/// <summary>남은 초 → "mm:ss" 를 char 버퍼에 직접 쓴다(문자열 0). 글자 수를 돌려준다.</summary>
|
||||
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) ────────────────────────────────
|
||||
/// <summary>UI 루트(NewGameUI)에서 부모를 찾아 HUD 노드를 만든다. 이미 물려 있으면 그대로.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>기존 HUD 라벨에서 폰트를 빌린다(아트 에셋 참조 0 · 한글 글리프 확보).</summary>
|
||||
public static TMP_FontAsset BorrowFont(Transform uiRoot)
|
||||
{
|
||||
if (uiRoot == null) return null;
|
||||
var labels = uiRoot.GetComponentsInChildren<TextMeshProUGUI>(true);
|
||||
for (int i = 0; i < labels.Length; i++)
|
||||
if (labels[i] != null && labels[i].font != null) return labels[i].font;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>폰트를 직접 지정한다(프로브·QA).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>존 칸을 개수에 맞춰 만든다(개수가 그대로면 아무 것도 하지 않는다).</summary>
|
||||
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<Image>();
|
||||
if (bg == null) bg = cell.gameObject.AddComponent<Image>();
|
||||
bg.raycastTarget = false;
|
||||
var fillRt = WLTextFxUtil.NewChild(cell, "Fill");
|
||||
WLTextFxUtil.Stretch(fillRt);
|
||||
var fill = fillRt.GetComponent<Image>();
|
||||
if (fill == null) fill = fillRt.gameObject.AddComponent<Image>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>SO 값으로 배치한다(px → 유닛 환산은 813c 산식 하나만 쓴다).</summary>
|
||||
public static string ApplyLayout()
|
||||
{
|
||||
var s = St;
|
||||
if (s == null) return "WLRunUiSettings 에셋 없음 — 배치 건너뜀";
|
||||
if (s_root == null) return "노드 없음 — Bind 먼저";
|
||||
|
||||
var canvas = s_root.GetComponentInParent<Canvas>(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");
|
||||
}
|
||||
|
||||
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
|
||||
/// <summary>카운터·표시 상태만 초기화(노드는 남긴다).</summary>
|
||||
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 + ")";
|
||||
}
|
||||
|
||||
/// <summary>노드까지 되돌린다(프로브 종료용).</summary>
|
||||
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 완료";
|
||||
}
|
||||
|
||||
/// <summary>존 칸의 현재 채움값(실측 덤프용).</summary>
|
||||
public static float ZoneFill(int i) { return i >= 0 && i < s_zoneCells ? s_zoneFillValue[i] : -1f; }
|
||||
/// <summary>존 칸의 현재 펄스 스케일(실측 덤프용).</summary>
|
||||
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<SafeAreaFitter>(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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 🔴 **검증 전용** — 에디트 모드 프로브가 813p `RunEvents` 의 **실제 Dispatch 경로**로
|
||||
/// 가짜 이벤트를 흘려 넣는 진입점(813g/813y `LootToast.RaiseFakeKilled` 선례).
|
||||
///
|
||||
/// `CombatEventList<T>.Dispatch` 는 `internal` 이라 Assets 안(같은 Assembly-CSharp)에서만 부를 수 있다.
|
||||
/// 전부 **void** 다 — 반환 문자열을 만들면 GC 측정이 오염되기 때문이다(카운터는 `RunEvents.*.Dispatched`).
|
||||
/// 게임 코드는 이 클래스를 한 번도 부르지 않는다.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>보스는 일부러 null 로 둔다 — 813p 계약의 "null 가능" 경로를 그대로 태운다.</summary>
|
||||
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); }
|
||||
}
|
||||
|
||||
/// <summary>런 UI(HUD · 결과 화면)를 매 프레임 돌리는 런타임 전용 러너(씬에 굽지 않는다).</summary>
|
||||
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<WLRunUiRunner>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1bda35cb1de246388cd62b92312a64b3
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <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;
|
||||
|
||||
// ── 복사 버퍼 (🔴 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; }
|
||||
/// <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); }
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>부활 팝업이 비면 띄운다. 아직 떠 있으면 미뤄 둔다(러너가 다시 부른다).</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <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 위로
|
||||
LastShowLog = "런타임 패널 표시 outcome=" + s_result.outcome + " 행=" + VisibleRows;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
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<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;
|
||||
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_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);
|
||||
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);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9e596bedd98c4fbe8c459d870b490231
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5ac814f7604d40f9a34400430250e007, type: 3}
|
||||
m_Name: WLRunUiSettings
|
||||
m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLRunUiSettings
|
||||
enabled_: 1
|
||||
verboseLog: 0
|
||||
runHudEnabled: 1
|
||||
runHudParentPath: IngameUIs/WL_HUD
|
||||
runHudTopMarginPx: 272
|
||||
runHudCenterOffsetXPx: 0
|
||||
runHudWidthPx: 560
|
||||
runHudHideOnEnd: 1
|
||||
timerEnabled: 1
|
||||
timerFontPx: 56
|
||||
timerColor: {r: 0.949, g: 0.961, b: 0.98, a: 1}
|
||||
timerWarnSeconds: 30
|
||||
timerWarnColor: {r: 1, g: 0.29, b: 0.278, a: 1}
|
||||
timerEdge:
|
||||
enabled: 1
|
||||
outlineWidth: 0.28
|
||||
outlineColor: {r: 0.05, g: 0.03, b: 0.02, a: 1}
|
||||
shadowOffset: {x: 0.5, y: -0.5}
|
||||
shadowColor: {r: 0, g: 0, b: 0, a: 0.8}
|
||||
shadowSoftness: 0.22
|
||||
faceDilate: 0.08
|
||||
zoneGaugeEnabled: 1
|
||||
zoneGaugeTopGapPx: 10
|
||||
zoneCellHeightPx: 18
|
||||
zoneCellGapPx: 8
|
||||
zoneCellBgColor: {r: 0.05, g: 0.05, b: 0.06, a: 0.78}
|
||||
zoneCellFillColor: {r: 0.99, g: 0.78, b: 0.3, a: 1}
|
||||
zoneCellClearColor: {r: 0.137, g: 0.906, b: 0.529, a: 1}
|
||||
zonePulseScale: 1.35
|
||||
zonePulseSeconds: 0.3
|
||||
phaseLabelEnabled: 1
|
||||
phaseTopGapPx: 8
|
||||
phaseFontPx: 30
|
||||
phaseZoneFormat: "\uC874 {0}"
|
||||
phaseBossText: BOSS
|
||||
phaseColor: {r: 0.9, g: 0.91, b: 0.94, a: 1}
|
||||
phaseBossColor: {r: 1, g: 0.62, b: 0.24, a: 1}
|
||||
phaseEdge:
|
||||
enabled: 1
|
||||
outlineWidth: 0.24
|
||||
outlineColor: {r: 0.05, g: 0.03, b: 0.02, a: 1}
|
||||
shadowOffset: {x: 0.4, y: -0.4}
|
||||
shadowColor: {r: 0, g: 0, b: 0, a: 0.75}
|
||||
shadowSoftness: 0.18
|
||||
faceDilate: 0.06
|
||||
resultEnabled: 1
|
||||
resultParentPath: IngameUIs/WL_HUD
|
||||
resultUseCommonPopup: 0
|
||||
resultShowOnAbandon: 0
|
||||
resultDeferWhileRevive: 1
|
||||
resultWinTitle: "\uB7F0 \uD074\uB9AC\uC5B4!"
|
||||
resultTimeoutTitle: "\uC2DC\uAC04 \uC885\uB8CC"
|
||||
resultAbandonTitle: "\uB7F0 \uC911\uB2E8"
|
||||
resultDashText: "\u2014"
|
||||
resultBoxSizePx: {x: 860, y: 1080}
|
||||
resultDimColor: {r: 0, g: 0, b: 0, a: 0.62}
|
||||
resultBgColor: {r: 0.06, g: 0.07, b: 0.09, a: 0.94}
|
||||
resultPadXPx: 40
|
||||
resultPadTopPx: 36
|
||||
resultTitleFontPx: 56
|
||||
resultTitleWinColor: {r: 1, g: 0.86, b: 0.42, a: 1}
|
||||
resultTitleFailColor: {r: 0.86, g: 0.88, b: 0.92, a: 1}
|
||||
resultTitleGapPx: 28
|
||||
resultRowFontPx: 34
|
||||
resultRowHeightPx: 56
|
||||
resultRowLabelColor: {r: 0.7, g: 0.73, b: 0.79, a: 1}
|
||||
resultRowValueColor: {r: 0.96, g: 0.97, b: 0.99, a: 1}
|
||||
resultEdge:
|
||||
enabled: 1
|
||||
outlineWidth: 0.22
|
||||
outlineColor: {r: 0.05, g: 0.03, b: 0.02, a: 1}
|
||||
shadowOffset: {x: 0.35, y: -0.35}
|
||||
shadowColor: {r: 0, g: 0, b: 0, a: 0.7}
|
||||
shadowSoftness: 0.15
|
||||
faceDilate: 0.05
|
||||
resultRows:
|
||||
- 0
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
- 8
|
||||
rowTotalTimeLabel: "\uCD1D \uC2DC\uAC04"
|
||||
rowBossClearLabel: "\uBCF4\uC2A4 \uCC98\uCE58"
|
||||
rowKillsLabel: "\uCC98\uCE58 \uC218"
|
||||
rowMaxChainLabel: "\uCD5C\uB300 \uC5F0\uC1C4"
|
||||
rowMaxDamageLabel: "\uCD5C\uB300 \uD53C\uD574"
|
||||
rowLevelLabel: "\uB808\uBCA8"
|
||||
rowLootLabel: "\uD68D\uB4DD"
|
||||
rowGoldLabel: "\uACE8\uB4DC"
|
||||
rowDeathsLabel: "\uC0AC\uB9DD / \uBB3C\uC57D"
|
||||
rowKillsFormat: "{0} (\uBCF4\uC2A4 {1})"
|
||||
rowLevelFormat: "{0} \u2192 {1}"
|
||||
rowDeathsFormat: '{0} / {1}'
|
||||
rowLootGradeFormat: '{0}'
|
||||
rowLootGradeSeparator: ' '
|
||||
rowLootEmptyText: '0'
|
||||
resultMaxDamageUseBurst: 1
|
||||
resultHideLevelWhenZero: 1
|
||||
resultNextRunText: "\uB2E4\uC74C \uB7F0"
|
||||
resultCloseText: "\uB2EB\uAE30"
|
||||
resultButtonSizePx: {x: 340, y: 104}
|
||||
resultButtonGapPx: 28
|
||||
resultButtonBottomPx: 36
|
||||
resultButtonFontPx: 38
|
||||
resultNextBgColor: {r: 0.137, g: 0.906, b: 0.529, a: 1}
|
||||
resultNextLabelColor: {r: 0.04, g: 0.09, b: 0.06, a: 1}
|
||||
resultCloseBgColor: {r: 0.16, g: 0.18, b: 0.22, a: 1}
|
||||
resultCloseLabelColor: {r: 0.9, g: 0.92, b: 0.95, a: 1}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 41eba6abb5d84056868ff90a0301735d
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLRunUiSettings.cs — 런 HUD · 런 결과 화면 값의 단일 출처 (WL-813q · #813)
|
||||
//
|
||||
// 기준서 §D-2 813q 행 + 발주서 WL-813q §1 (Lead 설계 결정).
|
||||
// 구독 계약 = 813p 완료보고 §4 `RunEvents` 표(`Assets/WL/Combat/Run/RunEvents.cs`).
|
||||
//
|
||||
// ■ 왜 이 에셋인가 (C45)
|
||||
// 위치·글자 크기·색·행 순서·문구를 코드 상수로 두지 않는다. 이 파일에는 "필드와 기본값"만 있고,
|
||||
// 게임이 읽는 값은 `Resources/WL/WLRunUiSettings.asset` 이다(PD·기획이 인스펙터에서 바꾼다).
|
||||
// 🔴 런 길이(`runSeconds`)·존 수 같은 **게임 값은 여기 없다** — 그건 813p `WLRunSettings` 소유이고
|
||||
// UI 는 `RunStarted/RunTick` 페이로드로 받는다(수치 중복 0).
|
||||
//
|
||||
// ■ 좌표계 (px)
|
||||
// 813c 와 같은 **세로 1080×1920 기준 화면 픽셀**. px → 캔버스 유닛 환산은
|
||||
// `WLHudLayoutSettings.UnitsPerPx(canvas)` **하나만** 쓴다(산식 중복 0 · 813tj 선례).
|
||||
//
|
||||
// ■ C8 롤백
|
||||
// `enabled = 0` → HUD·결과 화면 둘 다 구독 0 · 노드 0 · 표시 0.
|
||||
// 813p `RunDirector` 는 그대로 돈다(이벤트만 아무도 안 듣는다).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.UI
|
||||
{
|
||||
/// <summary>결과 화면 본문 행 종류 — 배열 순서 = 표시 순서 · 배열에 없으면 표시 안 함.</summary>
|
||||
public enum WLRunResultRow
|
||||
{
|
||||
/// <summary>총 시간(mm:ss).</summary>
|
||||
TotalTime = 0,
|
||||
/// <summary>보스 처치 시간(미발생 −1 → 대시 문구).</summary>
|
||||
BossClear = 1,
|
||||
/// <summary>처치 수(보스 n).</summary>
|
||||
Kills = 2,
|
||||
/// <summary>최대 연쇄 처치.</summary>
|
||||
MaxChain = 3,
|
||||
/// <summary>최대 피해(합산 또는 단일 — resultMaxDamageUseBurst).</summary>
|
||||
MaxDamage = 4,
|
||||
/// <summary>레벨 from → to.</summary>
|
||||
Level = 5,
|
||||
/// <summary>등급별 획득(0 인 등급 생략 · 등급 색).</summary>
|
||||
LootByGrade = 6,
|
||||
/// <summary>획득 골드.</summary>
|
||||
Gold = 7,
|
||||
/// <summary>사망 / 물약.</summary>
|
||||
DeathsPotions = 8,
|
||||
}
|
||||
|
||||
[CreateAssetMenu(fileName = "WLRunUiSettings", menuName = "WL/Run UI Settings", order = 33)]
|
||||
public sealed class WLRunUiSettings : ScriptableObject
|
||||
{
|
||||
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
|
||||
public const string ResourcesPath = "WL/WLRunUiSettings";
|
||||
|
||||
private static WLRunUiSettings s_instance;
|
||||
private static bool s_tried;
|
||||
|
||||
/// <summary>에셋이 없으면 null(호출부는 null 이면 전부 건너뛴다 = C8 롤백).</summary>
|
||||
public static WLRunUiSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_instance == null && !s_tried)
|
||||
{
|
||||
s_tried = true;
|
||||
s_instance = Resources.Load<WLRunUiSettings>(ResourcesPath);
|
||||
}
|
||||
return s_instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다.</summary>
|
||||
public static void ClearCache() { s_instance = null; s_tried = false; }
|
||||
|
||||
/// <summary>프로브·QA 용 런타임 스위치(에셋을 건드리지 않고 끈다).</summary>
|
||||
public static bool RuntimeDisabled;
|
||||
|
||||
/// <summary>런 UI 축이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
|
||||
public static bool Enabled
|
||||
{
|
||||
get
|
||||
{
|
||||
if (RuntimeDisabled) return false;
|
||||
var s = Instance;
|
||||
return s != null && s.enabled_;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 공통 ──────────────────────────────────────────────────────────────
|
||||
[Header("공통 (C8 롤백)")]
|
||||
[Tooltip("끄면 런 HUD·결과 화면 둘 다 구독 0 · 노드 0 · 표시 0. 813p RunDirector 는 그대로 돈다.")]
|
||||
// ScriptableObject 에 이미 `enabled` 라는 이름이 없지만(그건 MonoBehaviour), 혼동을 피해 뒤에 _ 를 붙인다.
|
||||
// 인스펙터·에셋 YAML 에는 `enabled_` 로 나온다.
|
||||
public bool enabled_ = true;
|
||||
|
||||
[Tooltip("켜면 런 UI 가 상태 전이마다 Console 에 1줄 남긴다(문자열을 만들기 전에 이 값으로 막는다 · GC 주의).")]
|
||||
public bool verboseLog = false;
|
||||
|
||||
// ── 런 HUD ────────────────────────────────────────────────────────────
|
||||
[Header("런 HUD — 배치 (Safe Area 안 · WL_HUD 자식)")]
|
||||
[Tooltip("끄면 HUD 노드를 만들지도 갱신하지도 않는다(결과 화면은 그대로).")]
|
||||
public bool runHudEnabled = true;
|
||||
|
||||
[Tooltip("HUD 를 붙일 부모 노드의 UI 루트 기준 경로(813y 실측 = IngameUIs/WL_HUD · SafeAreaFitter 아래).")]
|
||||
public string runHudParentPath = "IngameUIs/WL_HUD";
|
||||
|
||||
[Tooltip("Safe Area 상단에서 HUD 위쪽까지 여백(px). 813c 보스 HP 바(top 190 + 높이 66 = 256)와 겹치지 않게 그 아래.")]
|
||||
public float runHudTopMarginPx = 272f;
|
||||
|
||||
[Tooltip("가로 중앙 기준 오프셋(px · 양수 = 오른쪽). 0 = 상단 중앙.")]
|
||||
public float runHudCenterOffsetXPx = 0f;
|
||||
|
||||
[Tooltip("HUD 폭(px) — 존 게이지가 이 폭을 칸 수로 나눠 쓴다.")]
|
||||
public float runHudWidthPx = 560f;
|
||||
|
||||
[Tooltip("런이 끝나면(승리·시간 종료·이탈 모두) HUD 를 숨긴다.")]
|
||||
public bool runHudHideOnEnd = true;
|
||||
|
||||
[Header("런 HUD — 남은 시간")]
|
||||
[Tooltip("남은 시간 mm:ss 표시.")]
|
||||
public bool timerEnabled = true;
|
||||
|
||||
[Tooltip("타이머 글자 크기(px).")]
|
||||
public float timerFontPx = 56f;
|
||||
|
||||
[Tooltip("타이머 평상시 색.")]
|
||||
public Color timerColor = new Color(0.949f, 0.961f, 0.980f, 1f);
|
||||
|
||||
[Tooltip("남은 시간이 이 값 이하면 경고 색으로 바뀐다(초). 0 이면 경고 없음.")]
|
||||
public float timerWarnSeconds = 30f;
|
||||
|
||||
[Tooltip("경고 색(마지막 구간).")]
|
||||
public Color timerWarnColor = new Color(1f, 0.290f, 0.278f, 1f);
|
||||
|
||||
[Tooltip("타이머 아웃라인·그림자(813y WLTextFxUtil.ApplyEdge).")]
|
||||
public WLTextEdge timerEdge = new WLTextEdge
|
||||
{
|
||||
enabled = true,
|
||||
outlineWidth = 0.28f,
|
||||
outlineColor = new Color(0.05f, 0.03f, 0.02f, 1f),
|
||||
shadowOffset = new Vector2(0.5f, -0.5f),
|
||||
shadowColor = new Color(0f, 0f, 0f, 0.8f),
|
||||
shadowSoftness = 0.22f,
|
||||
faceDilate = 0.08f,
|
||||
};
|
||||
|
||||
[Header("런 HUD — 존 게이지")]
|
||||
[Tooltip("존 게이지 칸 표시(칸 수 = RunStarted.zoneCount).")]
|
||||
public bool zoneGaugeEnabled = true;
|
||||
|
||||
[Tooltip("타이머 아래 간격(px).")]
|
||||
public float zoneGaugeTopGapPx = 10f;
|
||||
|
||||
[Tooltip("칸 높이(px).")]
|
||||
public float zoneCellHeightPx = 18f;
|
||||
|
||||
[Tooltip("칸 사이 간격(px).")]
|
||||
public float zoneCellGapPx = 8f;
|
||||
|
||||
[Tooltip("빈 칸(배경) 색.")]
|
||||
public Color zoneCellBgColor = new Color(0.05f, 0.05f, 0.06f, 0.78f);
|
||||
|
||||
[Tooltip("진행 중인 칸의 채움 색(zoneKills / zoneRequired).")]
|
||||
public Color zoneCellFillColor = new Color(0.99f, 0.78f, 0.30f, 1f);
|
||||
|
||||
[Tooltip("클리어한 칸의 채움 색.")]
|
||||
public Color zoneCellClearColor = new Color(0.137f, 0.906f, 0.529f, 1f);
|
||||
|
||||
[Tooltip("존 클리어 순간 칸이 커졌다 돌아오는 배율(1 이면 펄스 없음).")]
|
||||
public float zonePulseScale = 1.35f;
|
||||
|
||||
[Tooltip("펄스 시간(초 · unscaled).")]
|
||||
public float zonePulseSeconds = 0.3f;
|
||||
|
||||
[Header("런 HUD — 단계 라벨")]
|
||||
[Tooltip("단계 라벨(\"존 N\" → 게이트 뒤 \"BOSS\") 표시.")]
|
||||
public bool phaseLabelEnabled = true;
|
||||
|
||||
[Tooltip("존 게이지 아래 간격(px).")]
|
||||
public float phaseTopGapPx = 8f;
|
||||
|
||||
[Tooltip("단계 라벨 글자 크기(px).")]
|
||||
public float phaseFontPx = 30f;
|
||||
|
||||
[Tooltip("존 단계 문구 — {0} = 존 번호(1부터).")]
|
||||
public string phaseZoneFormat = "존 {0}";
|
||||
|
||||
[Tooltip("보스 게이트 개방 뒤 문구.")]
|
||||
public string phaseBossText = "BOSS";
|
||||
|
||||
[Tooltip("존 단계 라벨 색.")]
|
||||
public Color phaseColor = new Color(0.90f, 0.91f, 0.94f, 1f);
|
||||
|
||||
[Tooltip("보스 단계 라벨 색.")]
|
||||
public Color phaseBossColor = new Color(1f, 0.62f, 0.24f, 1f);
|
||||
|
||||
[Tooltip("단계 라벨 아웃라인·그림자.")]
|
||||
public WLTextEdge phaseEdge = new WLTextEdge
|
||||
{
|
||||
enabled = true,
|
||||
outlineWidth = 0.24f,
|
||||
outlineColor = new Color(0.05f, 0.03f, 0.02f, 1f),
|
||||
shadowOffset = new Vector2(0.4f, -0.4f),
|
||||
shadowColor = new Color(0f, 0f, 0f, 0.75f),
|
||||
shadowSoftness = 0.18f,
|
||||
faceDilate = 0.06f,
|
||||
};
|
||||
|
||||
// ── 결과 화면 ─────────────────────────────────────────────────────────
|
||||
[Header("런 결과 화면 (RunEnded)")]
|
||||
[Tooltip("끄면 결과 화면을 만들지도 띄우지도 않는다(HUD 는 그대로).")]
|
||||
public bool resultEnabled = true;
|
||||
|
||||
[Tooltip("결과 화면을 붙일 부모 노드 경로(813tj ReviveDialog 와 같은 대역).")]
|
||||
public string resultParentPath = "IngameUIs/WL_HUD";
|
||||
|
||||
[Tooltip("공용 팝업(Popup.Ins · SortOrder_5)에 본문을 밀어 넣는다. 실측상 공용 팝업은 본문 라벨 1개 + " +
|
||||
"프리팹 고정 버튼 문구라 \"다음 런\" 문구를 프리팹 수정 없이 넣을 수 없다 → 기본 off(런타임 패널).")]
|
||||
public bool resultUseCommonPopup = false;
|
||||
|
||||
[Tooltip("이탈(Abandon)에도 결과 화면을 띄운다. 발주서 §1-2 = 띄우지 않는다(HUD 리셋만).")]
|
||||
public bool resultShowOnAbandon = false;
|
||||
|
||||
[Tooltip("부활 팝업(813i/813tj)이 떠 있거나 예약 중이면 결과 화면을 미뤘다가 닫힌 뒤 띄운다.")]
|
||||
public bool resultDeferWhileRevive = true;
|
||||
|
||||
[Tooltip("승리 제목.")]
|
||||
public string resultWinTitle = "런 클리어!";
|
||||
|
||||
[Tooltip("시간 종료 제목.")]
|
||||
public string resultTimeoutTitle = "시간 종료";
|
||||
|
||||
[Tooltip("이탈 제목(resultShowOnAbandon 을 켰을 때만 쓰인다).")]
|
||||
public string resultAbandonTitle = "런 중단";
|
||||
|
||||
[Tooltip("값이 없을 때(−1) 쓰는 문구.")]
|
||||
public string resultDashText = "—";
|
||||
|
||||
[Header("결과 화면 — 배치 · 색")]
|
||||
[Tooltip("팝업 상자 크기(px).")]
|
||||
public Vector2 resultBoxSizePx = new Vector2(860f, 1080f);
|
||||
|
||||
[Tooltip("뒤 화면을 덮는 딤 색.")]
|
||||
public Color resultDimColor = new Color(0f, 0f, 0f, 0.62f);
|
||||
|
||||
[Tooltip("팝업 상자 바탕 색.")]
|
||||
public Color resultBgColor = new Color(0.06f, 0.07f, 0.09f, 0.94f);
|
||||
|
||||
[Tooltip("상자 안쪽 좌우 여백(px).")]
|
||||
public float resultPadXPx = 40f;
|
||||
|
||||
[Tooltip("상자 위쪽 여백(px).")]
|
||||
public float resultPadTopPx = 36f;
|
||||
|
||||
[Tooltip("제목 글자 크기(px).")]
|
||||
public float resultTitleFontPx = 56f;
|
||||
|
||||
[Tooltip("승리 제목 색.")]
|
||||
public Color resultTitleWinColor = new Color(1f, 0.86f, 0.42f, 1f);
|
||||
|
||||
[Tooltip("시간 종료·이탈 제목 색.")]
|
||||
public Color resultTitleFailColor = new Color(0.86f, 0.88f, 0.92f, 1f);
|
||||
|
||||
[Tooltip("제목 아래 본문까지 간격(px).")]
|
||||
public float resultTitleGapPx = 28f;
|
||||
|
||||
[Tooltip("본문 행 글자 크기(px).")]
|
||||
public float resultRowFontPx = 34f;
|
||||
|
||||
[Tooltip("본문 행 높이(px).")]
|
||||
public float resultRowHeightPx = 56f;
|
||||
|
||||
[Tooltip("행 이름(왼쪽) 색.")]
|
||||
public Color resultRowLabelColor = new Color(0.70f, 0.73f, 0.79f, 1f);
|
||||
|
||||
[Tooltip("행 값(오른쪽) 색.")]
|
||||
public Color resultRowValueColor = new Color(0.96f, 0.97f, 0.99f, 1f);
|
||||
|
||||
[Tooltip("본문 행 아웃라인·그림자.")]
|
||||
public WLTextEdge resultEdge = new WLTextEdge
|
||||
{
|
||||
enabled = true,
|
||||
outlineWidth = 0.22f,
|
||||
outlineColor = new Color(0.03f, 0.02f, 0.02f, 1f),
|
||||
shadowOffset = new Vector2(0.35f, -0.35f),
|
||||
shadowColor = new Color(0f, 0f, 0f, 0.7f),
|
||||
shadowSoftness = 0.15f,
|
||||
faceDilate = 0.05f,
|
||||
};
|
||||
|
||||
[Header("결과 화면 — 본문 행 (배열 순서 = 표시 순서 · 빼면 표시 안 함)")]
|
||||
[Tooltip("발주서 §1-2 기본 순서: 총 시간 · 보스 처치 · 처치 수 · 최대 연쇄 · 최대 피해 · 레벨 · 등급별 획득 · 골드 · 사망/물약")]
|
||||
public WLRunResultRow[] resultRows = new WLRunResultRow[]
|
||||
{
|
||||
WLRunResultRow.TotalTime,
|
||||
WLRunResultRow.BossClear,
|
||||
WLRunResultRow.Kills,
|
||||
WLRunResultRow.MaxChain,
|
||||
WLRunResultRow.MaxDamage,
|
||||
WLRunResultRow.Level,
|
||||
WLRunResultRow.LootByGrade,
|
||||
WLRunResultRow.Gold,
|
||||
WLRunResultRow.DeathsPotions,
|
||||
};
|
||||
|
||||
[Tooltip("행 이름 문구 — 위 배열의 순서와 무관하게 종류별로 붙는다.")]
|
||||
public string rowTotalTimeLabel = "총 시간";
|
||||
public string rowBossClearLabel = "보스 처치";
|
||||
public string rowKillsLabel = "처치 수";
|
||||
public string rowMaxChainLabel = "최대 연쇄";
|
||||
public string rowMaxDamageLabel = "최대 피해";
|
||||
public string rowLevelLabel = "레벨";
|
||||
public string rowLootLabel = "획득";
|
||||
public string rowGoldLabel = "골드";
|
||||
public string rowDeathsLabel = "사망 / 물약";
|
||||
|
||||
[Tooltip("처치 수 값 문구 — {0} = 전체 처치, {1} = 그중 보스.")]
|
||||
public string rowKillsFormat = "{0} (보스 {1})";
|
||||
|
||||
[Tooltip("레벨 값 문구 — {0} = 시작 레벨, {1} = 종료 레벨.")]
|
||||
public string rowLevelFormat = "{0} → {1}";
|
||||
|
||||
[Tooltip("사망/물약 값 문구 — {0} = 사망, {1} = 물약 사용.")]
|
||||
public string rowDeathsFormat = "{0} / {1}";
|
||||
|
||||
[Tooltip("등급별 획득 한 칸 문구 — {0} = 개수. 앞에 등급 색 태그가 붙는다.")]
|
||||
public string rowLootGradeFormat = "{0}";
|
||||
|
||||
[Tooltip("등급별 획득 칸 사이 구분 문구.")]
|
||||
public string rowLootGradeSeparator = " ";
|
||||
|
||||
[Tooltip("등급별 획득이 0 건일 때 값 문구.")]
|
||||
public string rowLootEmptyText = "0";
|
||||
|
||||
[Tooltip("최대 피해를 합산(DamageMergeEvent.total 최대 · maxDamageBurst)으로 본다. 끄면 단일 타격 최대(maxDamage).")]
|
||||
public bool resultMaxDamageUseBurst = true;
|
||||
|
||||
[Tooltip("레벨 행은 levelFrom·levelTo 가 둘 다 0 이면(에디트 모드 등) 감춘다.")]
|
||||
public bool resultHideLevelWhenZero = true;
|
||||
|
||||
[Header("결과 화면 — 버튼")]
|
||||
[Tooltip("\"다음 런\" 버튼 문구 — 누르면 RunDirector.Restart() 하나만 부른다(813p 공개 API).")]
|
||||
public string resultNextRunText = "다음 런";
|
||||
|
||||
[Tooltip("\"닫기\" 버튼 문구.")]
|
||||
public string resultCloseText = "닫기";
|
||||
|
||||
[Tooltip("버튼 크기(px).")]
|
||||
public Vector2 resultButtonSizePx = new Vector2(340f, 104f);
|
||||
|
||||
[Tooltip("버튼 사이 간격(px).")]
|
||||
public float resultButtonGapPx = 28f;
|
||||
|
||||
[Tooltip("상자 아래에서 버튼까지 여백(px).")]
|
||||
public float resultButtonBottomPx = 36f;
|
||||
|
||||
[Tooltip("버튼 글자 크기(px).")]
|
||||
public float resultButtonFontPx = 38f;
|
||||
|
||||
[Tooltip("\"다음 런\" 버튼 바탕 색.")]
|
||||
public Color resultNextBgColor = new Color(0.137f, 0.906f, 0.529f, 1f);
|
||||
|
||||
[Tooltip("\"다음 런\" 버튼 글자 색.")]
|
||||
public Color resultNextLabelColor = new Color(0.04f, 0.09f, 0.06f, 1f);
|
||||
|
||||
[Tooltip("\"닫기\" 버튼 바탕 색.")]
|
||||
public Color resultCloseBgColor = new Color(0.16f, 0.18f, 0.22f, 1f);
|
||||
|
||||
[Tooltip("\"닫기\" 버튼 글자 색.")]
|
||||
public Color resultCloseLabelColor = new Color(0.90f, 0.92f, 0.95f, 1f);
|
||||
|
||||
// ── 파생 헬퍼 (코드 상수 0) ───────────────────────────────────────────
|
||||
/// <summary>px → 캔버스 유닛 계수. 813c 의 산식 하나만 쓴다(중복 0).</summary>
|
||||
public float UnitsPerPx(Canvas canvas)
|
||||
{
|
||||
var hud = WLHudLayoutSettings.Instance;
|
||||
return hud != null ? hud.UnitsPerPx(canvas) : 1f;
|
||||
}
|
||||
|
||||
/// <summary>이 행을 표시하는가(배열에 있으면 표시).</summary>
|
||||
public bool HasRow(WLRunResultRow row)
|
||||
{
|
||||
if (resultRows == null) return false;
|
||||
for (int i = 0; i < resultRows.Length; i++) if (resultRows[i] == row) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>결과 제목(종료 사유별).</summary>
|
||||
public string TitleFor(WL.Combat.Run.RunOutcome outcome)
|
||||
{
|
||||
switch (outcome)
|
||||
{
|
||||
case WL.Combat.Run.RunOutcome.Win: return resultWinTitle;
|
||||
case WL.Combat.Run.RunOutcome.Timeout: return resultTimeoutTitle;
|
||||
default: return resultAbandonTitle;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>결과 제목 색(종료 사유별).</summary>
|
||||
public Color TitleColorFor(WL.Combat.Run.RunOutcome outcome)
|
||||
{
|
||||
return outcome == WL.Combat.Run.RunOutcome.Win ? resultTitleWinColor : resultTitleFailColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5ac814f7604d40f9a34400430250e007
|
||||
Loading…
Reference in New Issue