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

891 lines
48 KiB
C#
Raw Permalink 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;
// 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 = "";
/// <summary>지금 타이머 라벨에 들어 있는 색(실측 덤프용 · D-23 검증).</summary>
public static Color TimerColor { get { return s_timer != null ? s_timer.color : new Color(0f, 0f, 0f, 0f); } }
/// <summary>지금 경고색으로 보이는가(실측 덤프용).</summary>
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; } }
/// <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++;
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];
/// <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;
}
/// <summary>WL-815d — 스테이지 모드면 경고 초/색을 `WLStageUiSettings`(60 s 규칙 = 마지막 10 s)에서 가져온다.</summary>
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` 하나뿐이다.
// ═════════════════════════════════════════════════════════════════════
/// <summary>스테이지 모드인가(실측 덤프용).</summary>
public static bool StageMode { get { return s_stageMode; } }
/// <summary>지금 화면의 남은 적 줄(실측 덤프용).</summary>
public static string EnemyText { get { return s_enemy != null ? s_enemy.text : ""; } }
/// <summary>지금 화면의 알림 줄(카운트다운 · 실측 덤프용).</summary>
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; } }
/// <summary>존 게이지가 지금 화면에 있는가(스테이지 모드에서는 접힌다 · 실측 덤프용).</summary>
public static bool ZoneGaugeVisible { get { return s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf; } }
/// <summary>남은 적 줄이 지금 화면에 있는가.</summary>
public static bool EnemyVisible { get { return s_enemy != null && s_enemy.gameObject.activeSelf; } }
/// <summary>스테이지 모드 on/off. off 로 되돌리면 813q 런 HUD 표시가 그대로 살아난다.</summary>
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);
}
/// <summary>「스테이지 N」 라벨(값이 그대로면 아무 것도 쓰지 않는다).</summary>
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++;
}
/// <summary>
/// WL-816m — 던전 중 단계 라벨(「던전 1」). 이름은 <see cref="StageUiBridge"/> 가
/// 던전 표(`WLDungeonDef.displayName`)에서 읽어 넘긴다 — 이 파일은 그리기만 한다.
/// 같은 문구면 쓰지 않는다(GC 0).
/// </summary>
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++;
}
/// <summary>"{0}" 한 자리를 문자열로 치환(입장 1회 경로 · 틱 부담 0).</summary>
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);
}
/// <summary>「남은 적 M / T」(값이 그대로면 쓰기 0 = GC 0).</summary>
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++;
}
/// <summary>스테이지 타이머(남은 초). 경고 구간은 `WLStageUiSettings.timerWarnSeconds`(10 s).</summary>
public static void WriteStageTime(float remaining)
{
s_remaining = remaining;
WriteTimer(remaining);
}
/// <summary>알림 한 줄(카운트다운 「준비」 등). null·빈 문자열이면 숨긴다.</summary>
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) ────────────────────────────────
/// <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);
// 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;
}
/// <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);
// WL-815d — 스테이지 모드면 존 게이지를 접고(§F-2) 남은 적 줄을 편다.
var g = WLStageUiSettings.Instance;
bool stage = s_stageMode && g != null;
bool zoneOn = s.zoneGaugeEnabled && !(stage && g.hideZoneGaugeInStage);
float enemyH = stage ? g.enemyFontPx : 0f;
float zoneH = zoneOn && 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)
+ (enemyH > 0f ? g.enemyTopGapPx + enemyH : 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 ? WarnColor(s) : 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(zoneOn);
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);
}
y -= phaseH * u;
// WL-815d — ③ 남은 적 M / T (스테이지 모드에서만 편다)
if (s_enemy != null)
{
if (enemyH > 0f)
{
y -= g.enemyTopGapPx * u;
var rt = s_enemy.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, enemyH * u);
s_enemy.fontSize = enemyH * u;
s_enemy.alignment = TextAlignmentOptions.Center;
s_enemy.color = s_shownEnemyHi ? g.enemyHighlightColor : g.enemyColor;
WLTextFxUtil.ApplyEdge(s_enemy, s.phaseEdge);
y -= enemyH * u;
}
if (s_enemy.gameObject.activeSelf != (enemyH > 0f)) s_enemy.gameObject.SetActive(enemyH > 0f);
}
// WL-815d — 카운트다운 「준비」 한 줄(3요소 아래 · 표시할 때만 켠다)
if (s_notice != null && g != null)
{
var rt = s_notice.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 - g.countdownTopGapPx * u);
rt.sizeDelta = new Vector2(0f, g.countdownFontPx * u);
s_notice.fontSize = g.countdownFontPx * u;
s_notice.alignment = TextAlignmentOptions.Center;
s_notice.color = g.countdownColor;
WLTextFxUtil.ApplyEdge(s_notice, s.timerEdge);
}
return "pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta +
" 칸=" + s_zoneCells + " 스테이지모드=" + s_stageMode +
" 존게이지=" + zoneOn + " 남은적=" + (enemyH > 0f) +
" unitsPerPx=" + u.ToString("F4");
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
/// <summary>카운터·표시 상태만 초기화(노드는 남긴다).</summary>
public static string ResetState()
{
StartedSeen = TickSeen = ZoneClearedSeen = GateSeen = BossSeen = EndedSeen = 0;
TimerWrites = PhaseWrites = FillWrites = TimerColorWrites = 0;
StageLabelWrites = EnemyWrites = NoticeWrites = StageEventsIgnored = 0; // WL-815d
s_shownSec = int.MinValue; s_shownWarn = false; s_timerColorDirty = false;
s_shownPhaseKind = -1; s_shownPhaseZone = -1;
s_shownStageNo = int.MinValue;
s_shownDungeonLabel = null; // WL-816m
s_shownEnemyRemain = s_shownEnemyTotal = int.MinValue; s_shownEnemyHi = false;
s_shownNotice = null;
if (s_notice != null && s_notice.gameObject.activeSelf) s_notice.gameObject.SetActive(false);
s_bossPhase = false; s_remaining = 0f;
ResetZoneCells();
return "상태 초기화(칸 " + s_zoneCells + " · 스테이지모드 " + s_stageMode + ")";
}
/// <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_enemy = null; s_notice = null; s_stageMode = false; // WL-815d
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.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<SafeAreaFitter>(true) != null));
sb.AppendLine(" [815d] 스테이지모드=" + s_stageMode + " 단계=\"" + PhaseText + "\"(쓰기 " + StageLabelWrites +
") 남은적=\"" + EnemyText + "\"(쓰기 " + EnemyWrites + ") 알림=\"" + NoticeText +
"\"(보임 " + NoticeVisible + " · 쓰기 " + NoticeWrites + ") · 무시한 RunEvents=" + StageEventsIgnored +
" · 존게이지활성=" + (s_zoneRoot != null && s_zoneRoot.gameObject.activeSelf) +
" · 경고초=" + (s != null ? WarnSeconds(s).ToString("F0") : "-") + "s");
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>WL-813q2 — 엘리트 집계 검증용 처치 이벤트(victim/killer 는 null · 같은 Dispatch 경로).</summary>
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);
}
}
/// <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);
}
}
}