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

636 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// ─────────────────────────────────────────────────────────────────────────────
// 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&lt;T&gt;.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);
}
}
}