// ───────────────────────────────────────────────────────────────────────────── // RunHud.cs — 런 HUD: 남은 시간(mm:ss) · 존 게이지 · 단계 라벨 (WL-813q · #813) // // 기준서 §D-2 813q 행 · 발주서 WL-813q §1-1. // 구독 계약 = 813p 완료보고 §4 표 = `Assets/WL/Combat/Run/RunEvents.cs`(**읽기만** · 수정 0). // RunStarted / RunTick / ZoneCleared / BossGateOpened / BossStarted / RunEnded // // ■ 프리팹 diff 0 (813y 방식) // 노드를 프리팹에 굽지 않는다 — 러너도 HUD 노드도 **런타임 생성**(`HideFlags.DontSave`). // 부모는 `IngameUIs/WL_HUD`(813y 실측 SafeAreaFitter 아래 = Safe Area 안 · 경로는 SO). // 보스 HP 바(813c · 상단 여백 190 px + 높이 66 px = 256 px)와 겹치지 않게 **그 아래**에 앉는다 // (기본 `runHudTopMarginPx` = 272 px · 값은 SO). // 전투 중 하단 메뉴 숨김(813y `HudCombatVisibility`)은 화면 **아래**를 만지므로 겹치지 않는다. // // ■ GC 0 (틱마다 문자열 0) // · mm:ss = char 버퍼 5글자를 직접 써서 `TMP_Text.SetText(char[], start, length)`. // · 그마저도 **표시값이 바뀐 초에만** 부른다(같은 초를 100번 틱해도 SetText 0회). // · 존 게이지 = `Image.fillAmount`(값이 바뀔 때만) · 펄스 = `localScale`. // · 단계 라벨 = 단계/존 번호가 바뀔 때만 `SetText(format, arg0)`(TMP 무할당 오버로드). // · 로그 문자열은 **만들기 전에** `verboseLog` 로 막는다(811b FIX-3 규약). // 🔴 에디터에서는 TMP 자체가 `#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로 // SetText 1회당 문자열 1개를 만든다(TMP_Text.cs). 이는 **TMP 의 에디터 전용 줄**이고 // 빌드에는 없다 — 프로브가 「같은 초 100틱 = 0 B」와 「변하는 초 100틱」을 나눠 잰다. // // ■ C8 롤백 — `WLRunUiSettings.enabled_ = 0`(또는 `runHudEnabled = 0`) → 구독 0 · 노드 0 · 표시 0. // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using System.Text; using TMPro; using UnityEngine; using UnityEngine.UI; using WL.Combat.Run; namespace WL.UI { /// 런 진행 HUD(정적 · 노드는 런타임 생성 · 프리팹 diff 0). public static class RunHud { // ── 노드 ────────────────────────────────────────────────────────────── private static Transform s_uiRoot; private static RectTransform s_root; private static TextMeshProUGUI s_timer; private static TextMeshProUGUI s_phase; private static RectTransform s_zoneRoot; private static Image[] s_zoneBg = new Image[0]; private static Image[] s_zoneFill = new Image[0]; private static RectTransform[] s_zoneCell = new RectTransform[0]; private static float[] s_zonePulseAt = new float[0]; private static float[] s_zoneFillValue = new float[0]; private static int s_zoneCells; private static TMP_FontAsset s_font; // ── 상태(값 캐시 = 변할 때만 그린다 = GC 0) ─────────────────────────── private static bool s_subscribed; private static bool s_visible; private static int s_shownSec = int.MinValue; private static bool s_shownWarn; // WL-813q2(D-23) — 다음 런에서 타이머 색을 **반드시 한 번** 다시 쓰게 하는 플래그. // 813q 버그: RunStarted 가 s_shownWarn 을 false 로 되돌리면 WriteTimer 의 // `warn != s_shownWarn` 이 false 가 되어 색 대입이 통째로 생략된다 → 이전 런의 경고색(붉은색) 잔존. private static bool s_timerColorDirty; 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 int TimerColorWrites; // WL-813q2(D-23) — 타이머 색을 실제로 쓴 횟수 public static string LastEdge = ""; /// 지금 타이머 라벨에 들어 있는 색(실측 덤프용 · D-23 검증). public static Color TimerColor { get { return s_timer != null ? s_timer.color : new Color(0f, 0f, 0f, 0f); } } /// 지금 경고색으로 보이는가(실측 덤프용). public static bool TimerWarn { get { return s_shownWarn; } } public static bool Subscribed { get { return s_subscribed; } } public static bool Bound { get { return s_root != null; } } public static bool Visible { get { return s_visible; } } public static int ZoneCells { get { return s_zoneCells; } } /// 지금 화면에 떠 있는 타이머 문자열(실측 덤프용 — 표시 경로가 아니다). public static string TimerText { get { return s_timer != null ? s_timer.text : ""; } } public static string PhaseText { get { return s_phase != null ? s_phase.text : ""; } } private static WLRunUiSettings St { get { return WLRunUiSettings.Instance; } } // ── 부팅 (노드 0 · 프리팹 diff 0) ───────────────────────────────────── [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Boot() { var s = St; if (!WLRunUiSettings.Enabled || s == null || !s.runHudEnabled) return; Subscribe(true); WLRunUiRunner.Ensure(); } // ── 구독 (813p RunEvents 6종) ───────────────────────────────────────── public static string Subscribe(bool on) { if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; if (on) { RunEvents.RunStarted.Add(OnRunStarted); RunEvents.RunTick.Add(OnRunTick); RunEvents.ZoneCleared.Add(OnZoneCleared); RunEvents.BossGateOpened.Add(OnBossGateOpened); RunEvents.BossStarted.Add(OnBossStarted); RunEvents.RunEnded.Add(OnRunEnded); } else { RunEvents.RunStarted.Remove(OnRunStarted); RunEvents.RunTick.Remove(OnRunTick); RunEvents.ZoneCleared.Remove(OnZoneCleared); RunEvents.BossGateOpened.Remove(OnBossGateOpened); RunEvents.BossStarted.Remove(OnBossStarted); RunEvents.RunEnded.Remove(OnRunEnded); } s_subscribed = on; return "구독=" + on + " (RunStarted/RunTick/ZoneCleared/BossGateOpened/BossStarted/RunEnded)"; } // ── 이벤트 핸들러 (in 파라미터 = 복사 0 · 813p 계약) ────────────────── private static void OnRunStarted(in RunStartedEvent e) { StartedSeen++; s_runSeconds = e.runSeconds; s_zoneCount = e.zoneCount; s_remaining = e.runSeconds; s_bossPhase = false; s_shownSec = int.MinValue; s_shownWarn = false; s_shownPhaseKind = -1; s_shownPhaseZone = -1; var st0 = St; if (st0 == null || st0.timerResetColorOnRunStart) s_timerColorDirty = true; // WL-813q2(D-23) BuildZoneCells(e.zoneCount); ResetZoneCells(); SetVisible(true); WriteTimer(e.runSeconds); WritePhase(false, 0); var s = St; if (s != null && s.verboseLog) Debug.Log("[RunHud] 런 시작 #" + e.runIndex + " — " + e.runSeconds + "s · 존 " + e.zoneCount); } private static void OnRunTick(in RunTickEvent e) { TickSeen++; s_remaining = e.remainingSec; if (e.zoneCount != s_zoneCount) { s_zoneCount = e.zoneCount; BuildZoneCells(e.zoneCount); } WriteTimer(e.remainingSec); // 클리어한 칸은 가득 · 현재 칸은 진행률 · 나머지는 0 (전부 변할 때만 쓴다) for (int i = 0; i < s_zoneCells; i++) { float v = i < e.zonesCleared ? 1f : (i == e.zoneIndex && e.zoneRequired > 0 ? Mathf.Clamp01((float)e.zoneKills / e.zoneRequired) : 0f); SetFill(i, v, i < e.zonesCleared); } bool boss = s_bossPhase || e.phase == RunPhase.BossGate || e.phase == RunPhase.Boss; WritePhase(boss, e.zoneIndex); } private static void OnZoneCleared(in ZoneClearedEvent e) { ZoneClearedSeen++; if (e.zoneIndex >= 0 && e.zoneIndex < s_zoneCells) { SetFill(e.zoneIndex, 1f, true); s_zonePulseAt[e.zoneIndex] = Time.unscaledTime; // 펄스 시작(러너가 되돌린다) } var s = St; if (s != null && s.verboseLog) Debug.Log("[RunHud] 존 클리어 #" + e.zoneIndex + " " + e.kills + "/" + e.required); } private static void OnBossGateOpened(in BossGateOpenedEvent e) { GateSeen++; s_bossPhase = true; WritePhase(true, 0); } private static void OnBossStarted(in BossStartedEvent e) { BossSeen++; s_bossPhase = true; // e.boss 는 null 일 수 있다(813p 계약) — 여기서는 쓰지 않는다 WritePhase(true, 0); } private static void OnRunEnded(in RunResult e) { EndedSeen++; s_bossPhase = false; var s = St; if (s != null && s.runHudHideOnEnd) SetVisible(false); } // ── 표시 (전부 "변할 때만" 쓴다) ────────────────────────────────────── private static readonly char[] s_timeBuf = new char[8]; /// 남은 초 → "mm:ss" 를 char 버퍼에 직접 쓴다(문자열 0). 글자 수를 돌려준다. public static int FormatMMSS(char[] buf, float seconds) { if (buf == null || buf.Length < 5) return 0; int t = seconds <= 0f ? 0 : Mathf.CeilToInt(seconds); int m = t / 60; int sec = t - m * 60; if (m > 99) { m = 99; sec = 59; } buf[0] = (char)('0' + m / 10); buf[1] = (char)('0' + m % 10); buf[2] = ':'; buf[3] = (char)('0' + sec / 10); buf[4] = (char)('0' + sec % 10); return 5; } private static void WriteTimer(float remaining) { var s = St; if (s_timer == null || s == null || !s.timerEnabled) return; int t = remaining <= 0f ? 0 : Mathf.CeilToInt(remaining); bool warn = s.timerWarnSeconds > 0f && remaining <= s.timerWarnSeconds; // WL-813q2(D-23) — 런 시작 직후 1회는 값이 같아도 색을 반드시 다시 쓴다(플래그는 여기서 바로 꺼진다 // → 같은 초 100틱에서 색 쓰기 0회 = GC 0 규약 유지). if (s_timerColorDirty) { s_timerColorDirty = false; s_shownWarn = warn; s_timer.color = warn ? s.timerWarnColor : s.timerColor; TimerColorWrites++; } 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; TimerColorWrites++; } int n = FormatMMSS(s_timeBuf, remaining); s_timer.SetText(s_timeBuf, 0, n); TimerWrites++; } private static void WritePhase(bool boss, int zoneIndex) { var s = St; if (s_phase == null || s == null || !s.phaseLabelEnabled) return; int kind = boss ? 1 : 0; int zone = boss ? -1 : zoneIndex; if (kind == s_shownPhaseKind && zone == s_shownPhaseZone) return; s_shownPhaseKind = kind; s_shownPhaseZone = zone; if (boss) { s_phase.SetText(s.phaseBossText); s_phase.color = s.phaseBossColor; } else { int shown = Mathf.Clamp(zoneIndex + 1, 1, Mathf.Max(1, s_zoneCount)); s_phase.SetText(s.phaseZoneFormat, shown); // TMP 무할당 오버로드({0} = float) s_phase.color = s.phaseColor; } PhaseWrites++; } private static void SetFill(int i, float v, bool cleared) { if (i < 0 || i >= s_zoneCells || s_zoneFill[i] == null) return; var s = St; if (Mathf.Abs(v - s_zoneFillValue[i]) < 0.0005f) return; // 값이 그대로면 아무 것도 하지 않는다 s_zoneFillValue[i] = v; s_zoneFill[i].fillAmount = v; if (s != null) { var want = cleared || v >= 0.999f ? s.zoneCellClearColor : s.zoneCellFillColor; if (s_zoneFill[i].color != want) s_zoneFill[i].color = want; } FillWrites++; } private static void ResetZoneCells() { for (int i = 0; i < s_zoneCells; i++) { s_zoneFillValue[i] = -1f; // 다음 SetFill 이 반드시 쓰게 한다 SetFill(i, 0f, false); s_zonePulseAt[i] = -9999f; if (s_zoneCell[i] != null) s_zoneCell[i].localScale = Vector3.one; } } public static void SetVisible(bool on) { s_visible = on; if (s_root != null && s_root.gameObject.activeSelf != on) s_root.gameObject.SetActive(on); } // ── 매 프레임(러너) — 펄스만 돌린다 ─────────────────────────────────── public static string Tick(float now) { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음"; if (!WLRunUiSettings.Enabled || !s.runHudEnabled) { if (s_visible) SetVisible(false); return "off — 무동작"; } float dur = Mathf.Max(0f, s.zonePulseSeconds); float peak = Mathf.Max(1f, s.zonePulseScale); for (int i = 0; i < s_zoneCells; i++) { var rt = s_zoneCell[i]; if (rt == null) continue; float k = dur <= 0f ? 1f : Mathf.Clamp01((now - s_zonePulseAt[i]) / dur); float sc = k >= 1f ? 1f : Mathf.Lerp(peak, 1f, k * k); if (!Mathf.Approximately(rt.localScale.x, sc)) rt.localScale = new Vector3(sc, sc, 1f); } return "tick ok"; } // ── 조립 (런타임 생성 · 프리팹 diff 0) ──────────────────────────────── /// UI 루트(NewGameUI)에서 부모를 찾아 HUD 노드를 만든다. 이미 물려 있으면 그대로. public static string Bind(Transform uiRoot) { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음"; if (!WLRunUiSettings.Enabled || !s.runHudEnabled) return "off — 조립 생략"; if (uiRoot == null) return "uiRoot=null"; if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name; s_uiRoot = uiRoot; var parent = WLVignetteUtil.FindUiPath(uiRoot, s.runHudParentPath); var prt = parent as RectTransform; if (prt == null) return "부모 없음 — 경로 \"" + s.runHudParentPath + "\""; if (s_font == null) s_font = BorrowFont(uiRoot); if (s_root == null || s_root.parent != prt) { var go = new GameObject("WL_RunHud", typeof(RectTransform)); go.layer = WLTextFxUtil.UILayer; go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다 s_root = (RectTransform)go.transform; s_root.SetParent(prt, false); s_timer = WLTextFxUtil.NewText(s_root, "Timer", s_font, TextAlignmentOptions.Center); s_zoneRoot = WLTextFxUtil.NewChild(s_root, "Zones"); s_phase = WLTextFxUtil.NewText(s_root, "Phase", s_font, TextAlignmentOptions.Center); s_zoneCells = 0; } BuildZoneCells(Mathf.Max(s_zoneCount, RunDirector.ZoneCount)); string layout = ApplyLayout(); SetVisible(RunDirector.IsRunning); return "연결 " + s.runHudParentPath + " · " + layout; } /// 기존 HUD 라벨에서 폰트를 빌린다(아트 에셋 참조 0 · 한글 글리프 확보). public static TMP_FontAsset BorrowFont(Transform uiRoot) { if (uiRoot == null) return null; var labels = uiRoot.GetComponentsInChildren(true); for (int i = 0; i < labels.Length; i++) if (labels[i] != null && labels[i].font != null) return labels[i].font; return null; } /// 폰트를 직접 지정한다(프로브·QA). public static void SetFont(TMP_FontAsset f) { s_font = f; if (f == null) return; if (s_timer != null) s_timer.font = f; if (s_phase != null) s_phase.font = f; } /// 존 칸을 개수에 맞춰 만든다(개수가 그대로면 아무 것도 하지 않는다). public static bool BuildZoneCells(int want) { want = Mathf.Max(0, want); if (s_zoneRoot == null) return false; if (want == s_zoneCells) return false; // 칸 수가 그대로면 다시 만들지 않는다 if (s_zoneCell.Length < want) { System.Array.Resize(ref s_zoneCell, want); System.Array.Resize(ref s_zoneBg, want); System.Array.Resize(ref s_zoneFill, want); System.Array.Resize(ref s_zonePulseAt, want); System.Array.Resize(ref s_zoneFillValue, want); } for (int i = 0; i < want; i++) { if (s_zoneCell[i] != null) continue; var cell = WLTextFxUtil.NewChild(s_zoneRoot, "Zone" + i); var bg = cell.GetComponent(); if (bg == null) bg = cell.gameObject.AddComponent(); bg.raycastTarget = false; var fillRt = WLTextFxUtil.NewChild(cell, "Fill"); WLTextFxUtil.Stretch(fillRt); var fill = fillRt.GetComponent(); if (fill == null) fill = fillRt.gameObject.AddComponent(); fill.raycastTarget = false; fill.type = Image.Type.Filled; fill.fillMethod = Image.FillMethod.Horizontal; fill.fillOrigin = (int)Image.OriginHorizontal.Left; fill.fillAmount = 0f; s_zoneCell[i] = cell; s_zoneBg[i] = bg; s_zoneFill[i] = fill; s_zonePulseAt[i] = -9999f; s_zoneFillValue[i] = -1f; } // 남는 칸은 파괴하지 않고 숨긴다(다음 런에서 다시 쓴다) for (int i = want; i < s_zoneCell.Length; i++) if (s_zoneCell[i] != null && s_zoneCell[i].gameObject.activeSelf) s_zoneCell[i].gameObject.SetActive(false); for (int i = 0; i < want; i++) if (s_zoneCell[i] != null && !s_zoneCell[i].gameObject.activeSelf) s_zoneCell[i].gameObject.SetActive(true); s_zoneCells = want; ApplyLayout(); return true; } /// SO 값으로 배치한다(px → 유닛 환산은 813c 산식 하나만 쓴다). public static string ApplyLayout() { var s = St; if (s == null) return "WLRunUiSettings 에셋 없음 — 배치 건너뜀"; if (s_root == null) return "노드 없음 — Bind 먼저"; var canvas = s_root.GetComponentInParent(true); float u = s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null); float zoneH = s.zoneGaugeEnabled && s_zoneCells > 0 ? s.zoneCellHeightPx : 0f; float phaseH = s.phaseLabelEnabled ? s.phaseFontPx : 0f; float totalH = s.timerFontPx + (zoneH > 0f ? s.zoneGaugeTopGapPx + zoneH : 0f) + (phaseH > 0f ? s.phaseTopGapPx + phaseH : 0f); s_root.anchorMin = s_root.anchorMax = new Vector2(0.5f, 1f); s_root.pivot = new Vector2(0.5f, 1f); s_root.anchoredPosition = new Vector2(s.runHudCenterOffsetXPx * u, -s.runHudTopMarginPx * u); s_root.sizeDelta = new Vector2(s.runHudWidthPx * u, totalH * u); s_root.localScale = Vector3.one; float y = 0f; if (s_timer != null) { var rt = s_timer.rectTransform; rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f); rt.pivot = new Vector2(0.5f, 1f); rt.offsetMin = new Vector2(0f, 0f); rt.offsetMax = new Vector2(0f, 0f); rt.anchoredPosition = new Vector2(0f, y); rt.sizeDelta = new Vector2(0f, s.timerFontPx * u); s_timer.fontSize = s.timerFontPx * u; s_timer.alignment = TextAlignmentOptions.Center; s_timer.color = s_shownWarn ? s.timerWarnColor : s.timerColor; s_timer.gameObject.SetActive(s.timerEnabled); LastEdge = WLTextFxUtil.ApplyEdge(s_timer, s.timerEdge); } y -= s.timerFontPx * u; if (s_zoneRoot != null) { y -= s.zoneGaugeTopGapPx * u; s_zoneRoot.anchorMin = new Vector2(0f, 1f); s_zoneRoot.anchorMax = new Vector2(1f, 1f); s_zoneRoot.pivot = new Vector2(0.5f, 1f); s_zoneRoot.offsetMin = new Vector2(0f, 0f); s_zoneRoot.offsetMax = new Vector2(0f, 0f); s_zoneRoot.anchoredPosition = new Vector2(0f, y); s_zoneRoot.sizeDelta = new Vector2(0f, zoneH * u); s_zoneRoot.gameObject.SetActive(s.zoneGaugeEnabled); int n = Mathf.Max(1, s_zoneCells); float totalGap = s.zoneCellGapPx * (n - 1) * u; float cellW = (s.runHudWidthPx * u - totalGap) / n; for (int i = 0; i < s_zoneCells; i++) { var cell = s_zoneCell[i]; if (cell == null) continue; cell.anchorMin = cell.anchorMax = new Vector2(0f, 0.5f); cell.pivot = new Vector2(0f, 0.5f); cell.anchoredPosition = new Vector2(i * (cellW + s.zoneCellGapPx * u), 0f); cell.sizeDelta = new Vector2(cellW, s.zoneCellHeightPx * u); if (s_zoneBg[i] != null) s_zoneBg[i].color = s.zoneCellBgColor; } y -= zoneH * u; } if (s_phase != null) { y -= s.phaseTopGapPx * u; var rt = s_phase.rectTransform; rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f); rt.pivot = new Vector2(0.5f, 1f); rt.offsetMin = new Vector2(0f, 0f); rt.offsetMax = new Vector2(0f, 0f); rt.anchoredPosition = new Vector2(0f, y); rt.sizeDelta = new Vector2(0f, s.phaseFontPx * u); s_phase.fontSize = s.phaseFontPx * u; s_phase.alignment = TextAlignmentOptions.Center; s_phase.gameObject.SetActive(s.phaseLabelEnabled); WLTextFxUtil.ApplyEdge(s_phase, s.phaseEdge); } return "pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta + " 칸=" + s_zoneCells + " unitsPerPx=" + u.ToString("F4"); } // ── 프로브 · 진단 ───────────────────────────────────────────────────── /// 카운터·표시 상태만 초기화(노드는 남긴다). public static string ResetState() { StartedSeen = TickSeen = ZoneClearedSeen = GateSeen = BossSeen = EndedSeen = 0; TimerWrites = PhaseWrites = FillWrites = TimerColorWrites = 0; s_shownSec = int.MinValue; s_shownWarn = false; s_timerColorDirty = false; s_shownPhaseKind = -1; s_shownPhaseZone = -1; s_bossPhase = false; s_remaining = 0f; ResetZoneCells(); return "상태 초기화(칸 " + s_zoneCells + ")"; } /// 노드까지 되돌린다(프로브 종료용). public static string Teardown() { Subscribe(false); if (s_root != null) { if (Application.isPlaying) Object.Destroy(s_root.gameObject); else Object.DestroyImmediate(s_root.gameObject); } s_root = null; s_timer = null; s_phase = null; s_zoneRoot = null; s_uiRoot = null; s_zoneCell = new RectTransform[0]; s_zoneBg = new Image[0]; s_zoneFill = new Image[0]; s_zonePulseAt = new float[0]; s_zoneFillValue = new float[0]; s_zoneCells = 0; s_visible = false; ResetState(); return "teardown 완료"; } /// 존 칸의 현재 채움값(실측 덤프용). public static float ZoneFill(int i) { return i >= 0 && i < s_zoneCells ? s_zoneFillValue[i] : -1f; } /// 존 칸의 현재 펄스 스케일(실측 덤프용). public static float ZoneScale(int i) { return i >= 0 && i < s_zoneCells && s_zoneCell[i] != null ? s_zoneCell[i].localScale.x : -1f; } public static string Dump() { var s = St; var sb = new StringBuilder(); sb.AppendLine("[RunHud] 구독=" + s_subscribed + " bound=" + Bound + " visible=" + s_visible + " · started=" + StartedSeen + " tick=" + TickSeen + " zoneCleared=" + ZoneClearedSeen + " gate=" + GateSeen + " boss=" + BossSeen + " ended=" + EndedSeen); sb.AppendLine(" 타이머 \"" + TimerText + "\" (남은 " + s_remaining.ToString("F1") + "s · 경고=" + s_shownWarn + " · 쓰기 " + TimerWrites + "회) · 단계 \"" + PhaseText + "\" (쓰기 " + PhaseWrites + "회)"); sb.AppendLine(" [813q2] 타이머 색 = " + ColorUtility.ToHtmlStringRGB(TimerColor) + " (색 쓰기 " + TimerColorWrites + "회 · 다음런리셋대기=" + s_timerColorDirty + " · 기본 " + (s != null ? ColorUtility.ToHtmlStringRGB(s.timerColor) : "-") + " / 경고 " + (s != null ? ColorUtility.ToHtmlStringRGB(s.timerWarnColor) : "-") + ")"); sb.Append(" 존 칸 " + s_zoneCells + " (fill 쓰기 " + FillWrites + "회):"); for (int i = 0; i < s_zoneCells; i++) sb.Append(" [").Append(i).Append("]=").Append(ZoneFill(i).ToString("F2")) .Append("×").Append(ZoneScale(i).ToString("F2")); sb.AppendLine(); if (s_root != null) sb.AppendLine(" 노드 pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta + " active=" + s_root.gameObject.activeSelf + " 부모=" + (s_root.parent != null ? s_root.parent.name : "-") + " SafeArea=" + (s_root.GetComponentInParent(true) != null)); sb.AppendLine(" edge = " + LastEdge); if (s != null) sb.AppendLine(" 설정 enabled=" + s.enabled_ + " hud=" + s.runHudEnabled + " topMargin=" + s.runHudTopMarginPx + "px width=" + s.runHudWidthPx + "px timerPx=" + s.timerFontPx + " warn=" + s.timerWarnSeconds + "s pulse=" + s.zonePulseScale + "×" + s.zonePulseSeconds + "s"); return sb.ToString(); } } /// /// 🔴 **검증 전용** — 에디트 모드 프로브가 813p `RunEvents` 의 **실제 Dispatch 경로**로 /// 가짜 이벤트를 흘려 넣는 진입점(813g/813y `LootToast.RaiseFakeKilled` 선례). /// /// `CombatEventList<T>.Dispatch` 는 `internal` 이라 Assets 안(같은 Assembly-CSharp)에서만 부를 수 있다. /// 전부 **void** 다 — 반환 문자열을 만들면 GC 측정이 오염되기 때문이다(카운터는 `RunEvents.*.Dispatched`). /// 게임 코드는 이 클래스를 한 번도 부르지 않는다. /// public static class WLRunUiProbeHooks { public static void RaiseRunStarted(int runIndex, float runSeconds, int zoneCount, string reason) { var e = new RunStartedEvent { runIndex = runIndex, startTime = Time.unscaledTime, runSeconds = runSeconds, zoneCount = zoneCount, reason = reason, time = Time.unscaledTime, frame = Time.frameCount, }; RunEvents.RunStarted.Dispatch(in e); } public static void RaiseRunTick(float elapsedSec, float remainingSec, int zoneIndex, int zoneKills, int zoneRequired, int zonesCleared, int zoneCount, RunPhase phase, int runIndex) { var e = new RunTickEvent { elapsedSec = elapsedSec, remainingSec = remainingSec, zoneIndex = zoneIndex, zoneKills = zoneKills, zoneRequired = zoneRequired, zonesCleared = zonesCleared, zoneCount = zoneCount, phase = phase, runIndex = runIndex, time = Time.unscaledTime, frame = Time.frameCount, }; RunEvents.RunTick.Dispatch(in e); } public static void RaiseZoneCleared(int zoneIndex, int spawnerId, float elapsedSec, int kills, int required, bool all) { var e = new ZoneClearedEvent { zoneIndex = zoneIndex, spawnerId = spawnerId, elapsedSec = elapsedSec, kills = kills, required = required, allZonesCleared = all, time = Time.unscaledTime, frame = Time.frameCount, }; RunEvents.ZoneCleared.Dispatch(in e); } public static void RaiseBossGateOpened(float elapsedSec, RunGateReason reason, int bossSpawnerId, bool armed) { var e = new BossGateOpenedEvent { elapsedSec = elapsedSec, reason = reason, bossSpawnerId = bossSpawnerId, arenaGateArmed = armed, time = Time.unscaledTime, frame = Time.frameCount, }; RunEvents.BossGateOpened.Dispatch(in e); } /// 보스는 일부러 null 로 둔다 — 813p 계약의 "null 가능" 경로를 그대로 태운다. public static void RaiseBossStarted(float elapsedSec, int spawnerId) { var e = new BossStartedEvent { elapsedSec = elapsedSec, spawnerId = spawnerId, boss = null, time = Time.unscaledTime, frame = Time.frameCount, }; RunEvents.BossStarted.Dispatch(in e); } public static void RaiseRunEnded(in RunResult r) { RunEvents.RunEnded.Dispatch(in r); } /// WL-813q2 — 엘리트 집계 검증용 처치 이벤트(victim/killer 는 null · 같은 Dispatch 경로). public static void RaiseKilled(eSubRol subRole) { var e = new WL.Combat.Core.KilledEvent { victim = null, killer = null, subRole = subRole, id = 0, position = Vector3.zero, byDirectHit = true, time = Time.unscaledTime, frame = Time.frameCount, }; WL.Combat.Core.CombatEvents.Killed.Dispatch(in e); } } /// 런 UI(HUD · 결과 화면)를 매 프레임 돌리는 런타임 전용 러너(씬에 굽지 않는다). internal sealed class WLRunUiRunner : MonoBehaviour { private static WLRunUiRunner s_ins; private NewGameUI _lastUi; internal static void Ensure() { if (s_ins != null) return; var go = new GameObject("WL_RunUiRunner"); go.hideFlags = HideFlags.HideAndDontSave; Object.DontDestroyOnLoad(go); s_ins = go.AddComponent(); } private void Update() { var ui = NewGameUI.Ins; if (ui != null && !ReferenceEquals(ui, _lastUi)) { _lastUi = ui; RunHud.Bind(ui.transform); RunResultUI.Bind(ui.transform); } float now = Time.unscaledTime; RunHud.Tick(now); RunResultUI.Tick(now); } } }