// ─────────────────────────────────────────────────────────────────────────────
// 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;
// ── WL-815d 스테이지 모드 ─────────────────────────────────────────────
// PD 추가 지시(#815 · 00:2x): 「화면 상단에는 스테이지 정보와 남은 시간, 그리고 처치할 적의
// 마릿수 정보만을 제공해야 함」 → 이 모드에서는 존 게이지를 숨기고 단계 라벨을 "스테이지 N" 으로,
// 그 아래에 "남은 적 M / T" 줄을 하나 더 둔다. 813p RunEvents 핸들러는 전부 **무시**한다
// (815b `mirrorRunHudEvents` 가 RunStarted/RunTick 을 병행 발행하기 때문 — 덮어쓰기 방지).
private static bool s_stageMode;
private static TextMeshProUGUI s_enemy; // "남은 적 M / T"
private static TextMeshProUGUI s_notice; // 카운트다운("준비") 등 한 줄 알림
private static int s_shownStageNo = int.MinValue;
private static string s_shownDungeonLabel; // WL-816m — 던전 라벨 캐시(같은 문구면 쓰기 0 = GC 0)
private static int s_shownEnemyRemain = int.MinValue, s_shownEnemyTotal = int.MinValue;
private static bool s_shownEnemyHi;
private static string s_shownNotice = null;
public static int StageLabelWrites, EnemyWrites, NoticeWrites;
public static int StageEventsIgnored; // 스테이지 모드에서 무시한 RunEvents 수(실측)
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
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++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d — 스테이지 병행 발행은 무시
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++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d
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 (s_stageMode) { StageEventsIgnored++; return; } // WL-815d — 813o 카드 트리거용 병행 발행
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++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d
s_bossPhase = true;
WritePhase(true, 0);
}
private static void OnBossStarted(in BossStartedEvent e)
{
BossSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d
s_bossPhase = true; // e.boss 는 null 일 수 있다(813p 계약) — 여기서는 쓰지 않는다
WritePhase(true, 0);
}
private static void OnRunEnded(in RunResult e)
{
EndedSeen++;
if (s_stageMode) { StageEventsIgnored++; return; } // WL-815d — 표시 종료는 StageEvents 가 쥔다
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;
}
/// WL-815d — 스테이지 모드면 경고 초/색을 `WLStageUiSettings`(60 s 규칙 = 마지막 10 s)에서 가져온다.
private static float WarnSeconds(WLRunUiSettings s)
{
if (s_stageMode)
{
var g = WLStageUiSettings.Instance;
if (g != null && WLStageUiSettings.Enabled) return g.timerWarnSeconds;
}
return s.timerWarnSeconds;
}
private static Color WarnColor(WLRunUiSettings s)
{
if (s_stageMode)
{
var g = WLStageUiSettings.Instance;
if (g != null && WLStageUiSettings.Enabled) return g.timerWarnColor;
}
return s.timerWarnColor;
}
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);
float warnSec = WarnSeconds(s);
bool warn = warnSec > 0f && remaining <= warnSec;
// WL-813q2(D-23) — 런 시작 직후 1회는 값이 같아도 색을 반드시 다시 쓴다(플래그는 여기서 바로 꺼진다
// → 같은 초 100틱에서 색 쓰기 0회 = GC 0 규약 유지).
if (s_timerColorDirty)
{
s_timerColorDirty = false;
s_shownWarn = warn;
s_timer.color = warn ? WarnColor(s) : 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 ? WarnColor(s) : 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);
}
// ═════════════════════════════════════════════════════════════════════
// WL-815d — 스테이지 모드 (상단 3요소만: 스테이지 N · 남은 시간 · 남은 적 M/T)
// 🔴 값은 전부 815b `StageEvents` 페이로드가 준다 — 이 파일은 그리기만 한다(게임 수치 0).
// 호출은 `StageUiBridge` 하나뿐이다.
// ═════════════════════════════════════════════════════════════════════
/// 스테이지 모드인가(실측 덤프용).
public static bool StageMode { get { return s_stageMode; } }
/// 지금 화면의 남은 적 줄(실측 덤프용).
public static string EnemyText { get { return s_enemy != null ? s_enemy.text : ""; } }
/// 지금 화면의 알림 줄(카운트다운 · 실측 덤프용).
public static string NoticeText { get { return s_notice != null ? s_notice.text : ""; } }
public static bool NoticeVisible { get { return s_notice != null && s_notice.gameObject.activeSelf; } }
/// 존 게이지가 지금 화면에 있는가(스테이지 모드에서는 접힌다 · 실측 덤프용).
public static bool ZoneGaugeVisible { get { return s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf; } }
/// 남은 적 줄이 지금 화면에 있는가.
public static bool EnemyVisible { get { return s_enemy != null && s_enemy.gameObject.activeSelf; } }
/// 스테이지 모드 on/off. off 로 되돌리면 813q 런 HUD 표시가 그대로 살아난다.
public static string SetStageMode(bool on)
{
if (on == s_stageMode) return "스테이지 모드 변화 없음(" + on + ")";
s_stageMode = on;
// 표시 캐시를 비워 다음 Write* 가 반드시 한 번 쓰게 한다(GC 0 규약: 값이 같으면 안 쓴다)
s_shownSec = int.MinValue; s_timerColorDirty = true;
s_shownPhaseKind = -1; s_shownPhaseZone = -1;
s_shownStageNo = int.MinValue;
s_shownDungeonLabel = null; // WL-816m
s_shownEnemyRemain = s_shownEnemyTotal = int.MinValue;
s_shownNotice = null;
if (!on) WriteNotice(null);
ApplyLayout();
return "스테이지 모드=" + on + " · 존게이지=" + (s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf) +
" 남은적줄=" + (s_enemy != null && s_enemy.gameObject.activeSelf);
}
/// 「스테이지 N」 라벨(값이 그대로면 아무 것도 쓰지 않는다).
public static void WriteStageLabel(int stageNo)
{
var g = WLStageUiSettings.Instance;
if (s_phase == null || g == null) return;
if (stageNo == s_shownStageNo) return;
s_shownStageNo = stageNo;
s_shownDungeonLabel = null; // WL-816m — 던전 캐시 무효화(던전 → 스테이지 복귀)
s_shownPhaseKind = 2; s_shownPhaseZone = stageNo; // 813q WritePhase 의 캐시와 충돌하지 않게
s_phase.SetText(g.stageLabelFormat, stageNo); // TMP 무할당 오버로드({0} = float)
var s = St;
if (s != null) s_phase.color = s.phaseColor;
StageLabelWrites++;
}
///
/// WL-816m — 던전 중 단계 라벨(「던전 1」). 이름은 가
/// 던전 표(`WLDungeonDef.displayName`)에서 읽어 넘긴다 — 이 파일은 그리기만 한다.
/// 같은 문구면 쓰지 않는다(GC 0).
///
public static void WriteDungeonLabel(string dungeonName)
{
var g = WLStageUiSettings.Instance;
if (s_phase == null || g == null || string.IsNullOrEmpty(dungeonName)) return;
string text = FormatName(g.dungeonLabelFormat, dungeonName);
if (string.Equals(text, s_shownDungeonLabel)) return;
s_shownDungeonLabel = text;
s_shownStageNo = int.MinValue; // 숫자 캐시 무효화(스테이지 복귀 시 다시 쓰게)
s_shownPhaseKind = 3; s_shownPhaseZone = -1; // 813q WritePhase 의 캐시와 충돌하지 않게
s_phase.SetText(text);
var s = St;
if (s != null) s_phase.color = s.phaseColor;
StageLabelWrites++;
}
/// "{0}" 한 자리를 문자열로 치환(입장 1회 경로 · 틱 부담 0).
private static string FormatName(string format, string a0)
{
if (string.IsNullOrEmpty(format)) return a0;
int i = format.IndexOf("{0}", System.StringComparison.Ordinal);
if (i < 0) return format;
return format.Substring(0, i) + a0 + format.Substring(i + 3);
}
/// 「남은 적 M / T」(값이 그대로면 쓰기 0 = GC 0).
public static void WriteEnemies(int remaining, int total)
{
var g = WLStageUiSettings.Instance;
if (s_enemy == null || g == null) return;
if (remaining == s_shownEnemyRemain && total == s_shownEnemyTotal) return;
s_shownEnemyRemain = remaining; s_shownEnemyTotal = total;
s_enemy.SetText(g.enemyLabelFormat, remaining, total); // TMP 무할당 오버로드(arg0, arg1)
bool hi = g.enemyHighlightAtOrBelow > 0 && remaining > 0 && remaining <= g.enemyHighlightAtOrBelow;
if (hi != s_shownEnemyHi) { s_shownEnemyHi = hi; s_enemy.color = hi ? g.enemyHighlightColor : g.enemyColor; }
EnemyWrites++;
}
/// 스테이지 타이머(남은 초). 경고 구간은 `WLStageUiSettings.timerWarnSeconds`(10 s).
public static void WriteStageTime(float remaining)
{
s_remaining = remaining;
WriteTimer(remaining);
}
/// 알림 한 줄(카운트다운 「준비」 등). null·빈 문자열이면 숨긴다.
public static void WriteNotice(string text)
{
if (s_notice == null) return;
bool on = !string.IsNullOrEmpty(text);
if (string.Equals(text, s_shownNotice)) return; // 같은 문구면 쓰지 않는다
s_shownNotice = text;
if (s_notice.gameObject.activeSelf != on) s_notice.gameObject.SetActive(on);
if (on) s_notice.SetText(text);
NoticeWrites++;
}
// ── 매 프레임(러너) — 펄스만 돌린다 ───────────────────────────────────
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);
// WL-815d — 스테이지 모드 전용 2줄(런 모드에서는 SetActive(false) 라 813q 화면은 그대로다)
s_enemy = WLTextFxUtil.NewText(s_root, "Enemies", s_font, TextAlignmentOptions.Center);
s_notice = WLTextFxUtil.NewText(s_root, "Notice", s_font, TextAlignmentOptions.Center);
s_enemy.gameObject.SetActive(false);
s_notice.gameObject.SetActive(false);
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