891 lines
48 KiB
C#
891 lines
48 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<T>.Dispatch` 는 `internal` 이라 Assets 안(같은 Assembly-CSharp)에서만 부를 수 있다.
|
||
/// 전부 **void** 다 — 반환 문자열을 만들면 GC 측정이 오염되기 때문이다(카운터는 `RunEvents.*.Dispatched`).
|
||
/// 게임 코드는 이 클래스를 한 번도 부르지 않는다.
|
||
/// </summary>
|
||
public static class WLRunUiProbeHooks
|
||
{
|
||
public static void RaiseRunStarted(int runIndex, float runSeconds, int zoneCount, string reason)
|
||
{
|
||
var e = new RunStartedEvent
|
||
{
|
||
runIndex = runIndex, startTime = Time.unscaledTime, runSeconds = runSeconds,
|
||
zoneCount = zoneCount, reason = reason, time = Time.unscaledTime, frame = Time.frameCount,
|
||
};
|
||
RunEvents.RunStarted.Dispatch(in e);
|
||
}
|
||
|
||
public static void RaiseRunTick(float elapsedSec, float remainingSec, int zoneIndex, int zoneKills,
|
||
int zoneRequired, int zonesCleared, int zoneCount, RunPhase phase, int runIndex)
|
||
{
|
||
var e = new RunTickEvent
|
||
{
|
||
elapsedSec = elapsedSec, remainingSec = remainingSec, zoneIndex = zoneIndex,
|
||
zoneKills = zoneKills, zoneRequired = zoneRequired, zonesCleared = zonesCleared,
|
||
zoneCount = zoneCount, phase = phase, runIndex = runIndex,
|
||
time = Time.unscaledTime, frame = Time.frameCount,
|
||
};
|
||
RunEvents.RunTick.Dispatch(in e);
|
||
}
|
||
|
||
public static void RaiseZoneCleared(int zoneIndex, int spawnerId, float elapsedSec, int kills, int required, bool all)
|
||
{
|
||
var e = new ZoneClearedEvent
|
||
{
|
||
zoneIndex = zoneIndex, spawnerId = spawnerId, elapsedSec = elapsedSec,
|
||
kills = kills, required = required, allZonesCleared = all,
|
||
time = Time.unscaledTime, frame = Time.frameCount,
|
||
};
|
||
RunEvents.ZoneCleared.Dispatch(in e);
|
||
}
|
||
|
||
public static void RaiseBossGateOpened(float elapsedSec, RunGateReason reason, int bossSpawnerId, bool armed)
|
||
{
|
||
var e = new BossGateOpenedEvent
|
||
{
|
||
elapsedSec = elapsedSec, reason = reason, bossSpawnerId = bossSpawnerId, arenaGateArmed = armed,
|
||
time = Time.unscaledTime, frame = Time.frameCount,
|
||
};
|
||
RunEvents.BossGateOpened.Dispatch(in e);
|
||
}
|
||
|
||
/// <summary>보스는 일부러 null 로 둔다 — 813p 계약의 "null 가능" 경로를 그대로 태운다.</summary>
|
||
public static void RaiseBossStarted(float elapsedSec, int spawnerId)
|
||
{
|
||
var e = new BossStartedEvent
|
||
{
|
||
elapsedSec = elapsedSec, spawnerId = spawnerId, boss = null,
|
||
time = Time.unscaledTime, frame = Time.frameCount,
|
||
};
|
||
RunEvents.BossStarted.Dispatch(in e);
|
||
}
|
||
|
||
public static void RaiseRunEnded(in RunResult r) { RunEvents.RunEnded.Dispatch(in r); }
|
||
|
||
/// <summary>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);
|
||
}
|
||
}
|
||
}
|