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

922 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// ─────────────────────────────────────────────────────────────────────────────
// BossHpBar.cs — 상단 보스 HP 바 + 페이즈 눈금 (WL-813c · #813)
//
// 기준서 v1 §B 요소 6 "등장 · 처치 연출": 등장 배너 + 상단 HP 바 + 페이즈 눈금(임계 100/70/40).
// §C 요소 6 현행: "상단 보스 HP 바 없음(타겟 패널 슬라이더만)".
//
// ■ 데이터 출처
// 811b 의 WL.Combat.Core.CombatEvents 를 **구독만** 한다 — Spawned(isBoss) / HitConfirmed / Killed.
// 발주서 §1-2: "이벤트 미도착 시 폴링 폴백 금지 대신 「미표시」" → 이벤트가 오지 않으면 바는 숨은 채로 둔다.
// (Update 에서 Actor 를 찾아다니는 폴백을 두지 않는다. 이벤트 배선 문제를 UI 가 가리면 안 된다.)
//
// ■ 값
// 크기 · 여백 · 눈금 위치 · 색 · 사라짐 지연은 전부 WLHudLayoutSettings 에셋(C45).
// 페이즈 **전투 임계값** 자체는 813h(Gameplay)가 테이블로 갖는다 — 여기 값은 **표시용 눈금**이다.
//
// ■ 숨김 방식
// GameObject 를 끄지 않는다(꺼지면 OnEnable 이 안 돌아 구독이 끊긴다).
// CanvasGroup alpha/blocksRaycasts 로만 숨긴다.
//
// ■ WL-813w8 §1-2 (2026-09-09) — 게이트 개방 전에는 바를 띄우지 않는다 (Q11 D-23 **재발**)
// 실측: 「다음 런」의 **존 사냥 내내 상단에 만피 빨간 보스 바**(캡처 2장 · `bhp=1800/1800 · balive=True`).
// 바 자체는 정상 추종이다 — 813w6 이 재스폰 보스를 **게이트 개방까지 살려서 고정**해 두는데,
// 813y 늦은 구독(`BindExistingBoss`)·813q2 런 시작 재바인드(`ResetForNewRun`)·`Spawned` 구독이
// 그 고정 보스를 물어 버리는 **표시 정책**이 문제였다.
// → 네 진입점 전부에 `BossArena.HideBossUiNow`(게이트 축 무장 + 미개방) 조건을 걸고,
// `RunEvents.BossGateOpened` 를 새로 구독해 **게이트가 열리는 순간** 늦은 구독 경로로 물린다.
// 🔴 첫 런 영향 0 — 첫 런 보스는 게이트 개방(`ForceOpenGate → Release`) **안에서** 등장한다(그때는 이미 개방).
// C8 롤백 = `WLBossArenaSettings.hideBossUiBeforeGate = 0` → 813q2/813y 동작 그대로.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Core;
using WL.Combat.Run;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class BossHpBar : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform bar;
[SerializeField] private Image background;
[SerializeField] private Image fill;
[SerializeField] private RectTransform tickRoot;
[SerializeField] private TextMeshProUGUI nameLabel;
[Header("이름 라벨 폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset nameFont;
[Tooltip("이름 라벨을 표시한다")]
[SerializeField] private bool showName = true;
private RectTransform _rt;
private Canvas _canvas;
private Actor _boss;
private bool _bound;
private float _maxHp, _curHp;
private float _hideAt = -1f;
private bool _subscribed;
// ── WL-813q2 — 이번 런에서 바가 실제로 본 보스 HP 비율(결과 화면 「보스 처치」 행이 읽는다) ──
// 🔴 813p `RunResult` 에는 보스 HP 필드가 없다(구조체 실측) → 이 값이 UI 자체 실측 원천이다.
// 1 = 이번 런에 보스를 한 번도 물지 않았다.
public static float RunBossHpRatio = -1f;
/// <summary>이번 런에 보스를 한 번이라도 물었는가(진단).</summary>
public static bool RunBossSeen;
/// <summary>런 시작 리셋 횟수(진단 · D-23).</summary>
public static int RunResetCount;
// ── WL-813w8(D-23 재발) — 게이트 전 보스 UI 숨김 진단 ──
/// <summary>게이트 전이라 바인드를 거절한 횟수(늦은 구독 · 런 시작 재바인드 · Spawned · 폴링).</summary>
public static int GateSuppressCount;
/// <summary>게이트 전이라 이미 물고 있던 바를 내린 횟수.</summary>
public static int GateHideCount;
/// <summary>게이트 개방 이벤트로 다시 물린 횟수.</summary>
public static int GateOpenBinds;
/// <summary>지금 게이트 전이라 보스 UI 를 숨겨야 하는가(Gameplay 판정을 그대로 읽는다 · 값은 WLBossArenaSettings).</summary>
public static bool SuppressedBeforeGate { get { return WL.Combat.Boss.BossArena.HideBossUiNow; } }
// ── WL-813w8 — 게이트가 열리는 순간 늦은 구독 경로로 물린다(813w6 BossGateOpened 이후 표시) ──
private void OnBossGateOpened(in BossGateOpenedEvent e)
{
if (_bound) return;
GateOpenBinds++;
LastGateOpenBind = BindExistingBoss();
}
/// <summary>마지막 게이트 개방 바인드 결과(진단).</summary>
public static string LastGateOpenBind = "";
/// <summary>마지막 갱신 결과 — 프로브(에디터)가 읽는 진단 값.</summary>
public float LastRatio { get; private set; }
public int SpawnedSeen { get; private set; }
public int HitSeen { get; private set; }
public int KilledSeen { get; private set; }
public bool Visible { get { return group != null && group.alpha > 0f; } }
private void Awake() { _rt = GetComponent<RectTransform>(); }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// <summary>
/// OnEnable 본체. 에디트 모드에서는 OnEnable 이 불리지 않으므로 검증 스크립트가 이걸 직접 부른다
/// (런타임과 **완전히 같은 경로**를 타야 검증이 의미가 있다).
/// </summary>
public string Initialize()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
string dedup = DedupInstances(); // WL-813c3 — 중복 바 차단(발주서 후보 ①)
HideNow();
Subscribe(true);
string late = BindExistingBoss(); // WL-813y — 늦은 구독 보정(Q3 결함 D-3)
return "initialized subscribed=" + _subscribed + " · " + layout + " · " + dedup + " · " + late;
}
/// <summary>
/// 바가 켜질 때 **이미 살아 있는 보스**가 있으면 즉시 물린다 (WL-813y · Q3 결함 D-3).
///
/// 필드 보스는 `FieldBossData.Start()` 로 **맵 로드 시** 스폰돼 `Spawned(isBoss)` 가
/// HUD 가 구독하기 **전에** 지나간다(Q3 실측: `SpawnedSeen=232 · _bound=False`).
/// 813d 가 만든 `BossArena.Boss`(게이트가 잡아 둔 현재 보스)를 1순위로 보고,
/// 없으면 씬에서 `MobActor` 를 1회만 훑어 `IsSubRole(eSubRol.Boss)` 인 살아 있는 개체를 찾는다.
/// </summary>
public string BindExistingBoss()
{
var s = WLHudLayoutSettings.Instance;
if (s != null && !s.bossBarEnabled) return "늦은구독: bossBarEnabled=false — 생략";
if (s != null && !s.bossBarBindExisting) return "늦은구독: bossBarBindExisting=false — 생략";
if (_bound) return "늦은구독: 이미 물림";
// 🔴 WL-813w8(D-23 재발) — 게이트 개방 전(813w6 고정 보스)에는 물지 않는다.
if (SuppressedBeforeGate) { GateSuppressCount++; return "늦은구독: 게이트 전 — 생략(813w8)"; }
Actor found = null;
string how = "";
// ① 813d 보스 아레나가 잡고 있는 보스(게이트 통과 시점에 채워진다)
var arenaBoss = WL.Combat.Boss.BossArena.Boss;
if (IsAliveBoss(arenaBoss)) { found = arenaBoss; how = "BossArena.Boss"; }
// ② 씬 1회 스캔 — 게이트 이전(맵 로드 시) 스폰까지 잡는다
if (found == null)
{
var mobs = Object.FindObjectsByType<MobActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
LateScanCount = mobs != null ? mobs.Length : 0;
for (int i = 0; mobs != null && i < mobs.Length; i++)
{
if (!IsAliveBoss(mobs[i])) continue;
found = mobs[i]; how = "FindObjectsByType<MobActor> " + LateScanCount + "개 스캔";
break;
}
}
if (found == null) return "늦은구독: 살아 있는 보스 없음(스캔 " + LateScanCount + ")";
LateBound = true;
return "늦은구독 " + how + " → " + Bind(found, null, DebugFallbackMaxHp);
}
private static bool IsAliveBoss(Actor a)
{
if (a == null) return false;
if (!a.IsSubRole(eSubRol.Boss)) return false;
if (a.IsDead()) return false;
return a.gameObject != null && a.gameObject.activeInHierarchy;
}
/// <summary>늦은 구독 보정으로 물었는가(진단).</summary>
public bool LateBound { get; private set; }
/// <summary>마지막 씬 스캔에서 본 MobActor 수(진단).</summary>
public int LateScanCount { get; private set; }
/// <summary>구독 등록/해제. OnEnable/OnDisable 이 부르고, 에디터 검증도 같은 것을 쓴다.</summary>
public void Subscribe(bool on)
{
if (on == _subscribed) return;
_subscribed = on;
if (on)
{
CombatEvents.Spawned.Add(OnSpawned);
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
CombatEvents.Killed.Add(OnKilled);
RunEvents.RunStarted.Add(OnRunStarted); // WL-813q2(D-23) — 다음 런 리셋
RunEvents.BossGateOpened.Add(OnBossGateOpened); // WL-813w8(D-23 재발) — 게이트 개방 = 표시 시점
}
else
{
CombatEvents.Spawned.Remove(OnSpawned);
CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
CombatEvents.Killed.Remove(OnKilled);
RunEvents.RunStarted.Remove(OnRunStarted);
RunEvents.BossGateOpened.Remove(OnBossGateOpened);
}
}
// ── WL-813q2(D-23) — 「다음 런」 뒤 이전 런의 보스 바가 남는다 ─────────
// `RunDirector.Restart()` 는 존 카운터·게이트만 되돌리고 UI 는 건드리지 않는다(Gameplay 소유).
// 그래서 UI 쪽에서 `RunStarted` 를 받아 스스로 리셋하고, 보스가 아직 살아 있으면
// 813y ⑤ 늦은 구독 경로(`BindExistingBoss`)를 그대로 다시 태워 물린다.
private void OnRunStarted(in RunStartedEvent e) { ResetForNewRun(); }
/// <summary>런 시작 리셋 — 바를 숨기고 물림을 푼 뒤, 살아 있는 보스가 있으면 다시 물린다.</summary>
public string ResetForNewRun()
{
var ui = WLRunUiSettings.Instance;
if (ui != null && !ui.bossResetOnRunStart) return "런시작리셋: bossResetOnRunStart=false — 생략";
RunResetCount++;
RunBossHpRatio = -1f; // 결과 화면이 읽는 런 단위 값도 여기서 비운다
RunBossSeen = false;
LateBound = false;
HideNow(); // _bound=false · _boss=null · alpha 0
SetRatio(0f);
_nextPoll = 0f;
if (ui != null && !ui.bossBarRebindOnRunStart) return "런시작리셋: 숨김만(재바인드 off)";
// ── WL-813c2 (Q6 D-23 잔여) ────────────────────────────────────────
// 813q2 의 리셋은 **돌고 있다**. 바가 그대로 남은 것처럼 보이는 실측 원인은 리셋 실패가 아니라
// **곧바로 이어지는 재바인드**다 — `RunDirector.Restart()`(:497) 는 `BossArena.ResetGate()` 만 부르고,
// `ResetGate()`(`BossArena.cs:630~638`)는 `s_boss` 를 **비우지 않는다**. 그래서 `BindExistingBoss()` 의
// ① `BossArena.Boss` 가 **이전 런의 살아 있는 보스**를 그대로 돌려주고 바가 즉시 다시 뜬다.
// → 이번 런의 보스가 실제로 등장(`BossArena.BossSpawned` · ResetGate 가 false 로 되돌린다)해야만 다시 문다.
// 🔴 WL-813w8(D-23 재발) — 813w6 은 재스폰 보스에도 `BossSpawned=true` 를 **유지**한다(813p 폴백을 위해).
// 그래서 아래 813c2 조건만으로는 「다음 런」의 고정 보스가 그대로 다시 물린다 = Q11 재발 그림이다.
// 게이트가 열리기 전에는 숨김만 하고, 개방 시점에 `OnBossGateOpened` 가 물린다.
if (SuppressedBeforeGate) { GateSuppressCount++; return "런시작리셋: 숨김만(게이트 전 · 813w8)"; }
var hud = WLHudLayoutSettings.Instance;
if (hud != null && hud.bossBarRebindRequiresArenaSpawn && !WL.Combat.Boss.BossArena.BossSpawned)
return "런시작리셋: 숨김만(이번 런 보스 미등장 · BossArena.BossSpawned=false)";
return "런시작리셋 → " + BindExistingBoss();
}
private void Update()
{
PollBoss(false);
if (_hideAt >= 0f && Time.unscaledTime >= _hideAt) HideNow();
}
// ── WL-813c2 (Q6 D-26) — 바가 실제 HP 를 안 따라간다 ────────────────────
// 실측 원인(같은 파일):
// ① `Bind()`(:311~) 가 `_maxHp` 를 **물릴 때 한 번만** 읽는다. `MyValue.Get_Stat`(`MyValue.cs:993`)
// 은 키가 아직 없으면 **0** 을 돌려주므로, 몹 스탯이 채워지기(`MyValue.cs:371`) 전에 물면
// `_maxHp = DebugFallbackMaxHp(100)` 로 굳는다 → `Clamp01(869/100) = 1.000` = **영원히 만피**.
// ② 갱신 경로가 `OnHitConfirmed`(:205~) 하나뿐이라 `Actor.Get_Damage` 를 안 타는 HP 변화
// (리쉬 회복 `BossArena.cs:507·512` · 리젠)는 바에 전혀 안 보인다.
// ③ 늦은 구독 씬 스캔(:118~)이 **첫 번째** 살아 있는 보스를 물어, 실제로 싸우는 아레나 보스가
// 아닐 수 있다(그러면 HitConfirmed victim 이 한 번도 일치하지 않아 갱신 0).
// → 물고 있는 보스의 현재 HP/MaxHP 를 **직접 폴링**해 fill 을 갱신한다. 값·주기·스위치 전부 SO.
// 🔴 GC 0 — 이 경로는 문자열을 만들지 않는다(진단은 전부 카운터).
/// <summary>폴링 실행 횟수(진단).</summary>
public int PollCount { get; private set; }
/// <summary>폴링이 아레나 보스로 갈아탄 횟수(진단 · 원인 ③).</summary>
public int PollRebindCount { get; private set; }
/// <summary>폴링이 굳어 있던 `_maxHp` 를 고친 횟수(진단 · 원인 ①).</summary>
public int MaxHpFixCount { get; private set; }
/// <summary>폴링이 마지막으로 읽은 현재 HP / 최대 HP(진단).</summary>
public float PolledHp { get { return _curHp; } }
public float PolledMaxHp { get { return _maxHp; } }
private float _nextPoll;
/// <summary>프로브 전용 — 합성(actor=null) 모드에서 폴링이 읽을 현재 HP(음수 = 쓰지 않음).</summary>
public static float DebugFakeHp = -1f;
/// <summary>프로브 전용 — 합성 모드에서 폴링이 읽을 최대 HP(0 이하 = 쓰지 않음).</summary>
public static float DebugFakeMaxHp = -1f;
/// <summary>
/// 물고 있는 보스의 HP 를 직접 읽어 fill 을 갱신한다. <paramref name="force"/> = 주기 무시(에디트 모드 프로브).
/// 돌려주는 값 = 이번 호출에서 실제로 갱신했는가.
/// </summary>
public bool PollBoss(bool force)
{
var s = WLHudLayoutSettings.Instance;
if (s == null || !s.bossBarEnabled || !s.bossBarPollEnabled) return false;
float now = Time.unscaledTime;
if (!force && s.bossBarPollHz > 0f)
{
if (now < _nextPoll) return false;
_nextPoll = now + 1f / s.bossBarPollHz;
}
// 🔴 WL-813w8(D-23 재발) — 게이트 전에는 새로 물지도, 물고 있던 것을 그리지도 않는다.
// (`bossBarPollFollowsArena` 는 813w6 이 유지하는 `BossSpawned=true` 때문에 그냥 두면 다시 문다.)
if (SuppressedBeforeGate)
{
if (_bound) { GateHideCount++; HideNow(); }
return false;
}
// ③ 아레나가 잡은 보스와 물고 있는 보스가 다르면 그쪽으로 갈아탄다.
// 🔴 **물고 있지 않을 때**(= 런 시작 리셋으로 숨긴 상태)는 D-23 을 되살리지 않게
// 이번 런의 보스가 실제로 등장했을 때만 새로 문다(ResetForNewRun 과 같은 조건).
if (s.bossBarPollFollowsArena)
{
var arena = WL.Combat.Boss.BossArena.Boss;
if (IsAliveBoss(arena) && !ReferenceEquals(arena, _boss) &&
(_bound || !s.bossBarRebindRequiresArenaSpawn || WL.Combat.Boss.BossArena.BossSpawned))
{
PollRebindCount++;
Bind(arena, null, DebugFallbackMaxHp); // Bind 안에서 MaxHP/HP 를 다시 읽는다
return true;
}
}
if (!_bound) return false;
if (ReferenceEquals(_boss, null)) return PollSynthetic(); // 합성 검증 모드(actor 없음)
if (_boss == null) { HideNow(); return false; } // 파괴된 액터(Unity null)
PollCount++;
// ① MaxHP 를 매번 다시 읽는다 — 늦게 채워져도 따라간다.
float max = ReadMaxHp(_boss);
if (max > 0f && max != _maxHp) { _maxHp = max; MaxHpFixCount++; }
// ② 현재 HP 도 매번 다시 읽는다 — 피해·회복·리젠 전부 반영된다.
float cur = ReadHp(_boss);
if (cur >= 0f) _curHp = Mathf.Max(0f, cur);
SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f);
if (s.bossBarPollHidesOnDeath && _boss.IsDead())
{
SetRatio(0f);
if (_hideAt < 0f) _hideAt = now + s.bossBarHideDelaySec;
}
else
{
// WL-813c3 — HP 가 0 인데 사망 판정이 아직 안 온 프레임에도 바를 내린다(발주서 §1-2).
var b = WLBossUiSettings.Instance;
if (b != null && b.bossBarHideOnZeroHp && _maxHp > 0f && _curHp <= 0f)
{
ZeroHpHideCount++;
if (_hideAt < 0f) _hideAt = now + s.bossBarHideDelaySec;
}
}
return true;
}
/// <summary>HP 0 숨김이 걸린 프레임 수(진단 · WL-813c3).</summary>
public int ZeroHpHideCount { get; private set; }
/// <summary>합성 모드 폴링 — 프로브가 넣은 가짜 HP 로 같은 산식을 태운다(게임 경로는 1 이라 무동작).</summary>
private bool PollSynthetic()
{
if (DebugFakeHp < 0f && DebugFakeMaxHp <= 0f) return false;
PollCount++;
if (DebugFakeMaxHp > 0f && DebugFakeMaxHp != _maxHp) { _maxHp = DebugFakeMaxHp; MaxHpFixCount++; }
if (DebugFakeHp >= 0f) _curHp = Mathf.Max(0f, DebugFakeHp);
SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f);
// WL-813c3 — 실액터 경로(PollBoss)와 같은 HP 0 숨김을 합성 경로에도 태운다(검증이 같은 코드를 봐야 한다).
var bs = WLBossUiSettings.Instance;
if (bs != null && bs.bossBarHideOnZeroHp && _maxHp > 0f && _curHp <= 0f)
{
ZeroHpHideCount++;
var hs = WLHudLayoutSettings.Instance;
if (_hideAt < 0f) _hideAt = Time.unscaledTime + (hs != null ? hs.bossBarHideDelaySec : 0f);
}
return true;
}
/// <summary>프로브 전용 — 합성 HP 주입을 지운다(게임 경로 오염 0).</summary>
public static void ClearFakeHp() { DebugFakeHp = -1f; DebugFakeMaxHp = -1f; }
// ── 이벤트 구독 (811b CombatEvents) ───────────────────────────────────
private void OnSpawned(in SpawnedEvent e)
{
SpawnedSeen++;
if (!e.isBoss) return;
// 🔴 WL-813w8(D-23 재발) — 게이트 전 보스 등장(= 813w6 이 그 자리에서 고정하는 재스폰)은 띄우지 않는다.
if (SuppressedBeforeGate) { GateSuppressCount++; return; }
Bind(e.actor, null, DebugFallbackMaxHp);
}
private void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!IsTracked(e.victim)) return;
HitSeen++;
float hp = ReadHp(e.victim);
_curHp = hp >= 0f ? hp : Mathf.Max(0f, _curHp - (float)e.damage);
SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f);
}
private void OnKilled(in KilledEvent e)
{
if (!IsTracked(e.victim)) return;
KilledSeen++;
_curHp = 0f;
SetRatio(0f);
var s = WLHudLayoutSettings.Instance;
_hideAt = Time.unscaledTime + (s != null ? s.bossBarHideDelaySec : 0f);
}
// 합성(에디터 검증) 이벤트는 actor 가 null 이다 — null == null 로 같은 경로를 탄다.
private bool IsTracked(Actor a) { return _bound && ReferenceEquals(a, _boss); }
private static float ReadHp(Actor a)
{
if (a == null) return -1f;
var st = a.Get_StatInfo();
if (st == null) return -1f;
return (float)st.Get_Stat(eStat.HP);
}
private static float ReadMaxHp(Actor a)
{
if (a == null) return -1f;
var st = a.Get_StatInfo();
if (st == null) return -1f;
return (float)st.Get_Stat(eStat.MaxHP);
}
// ── WL-813w §1-4 (Q4b D-12): HP 바 라벨이 `Anubis(Clone)` 로 뜬다 ───────────
// 등장 배너(813tj)는 MonsterList 이름("보스 몬스터 6")을 쓰는데 HP 바만 gameObject.name 을 썼다.
// 같은 경로(BossBanner.ResolveMonsterName = MonsterList.n_MonsterName → localtext)를 쓰고,
// 표를 못 읽을 때만 오브젝트 이름으로 떨어지되 `(Clone)` 접미사를 지운다.
// 이 경로는 Spawned 구독·늦은 구독(813y ⑤) **양쪽이 지나는 Bind() 안**이라 두 경우 모두 고쳐진다.
// C8 롤백 = WLBossArenaSettings.bossNameFromTable = 0 → 원본 gameObject.name 그대로.
/// <summary>마지막 라벨을 어느 경로로 얻었는가(진단 · "테이블"/"오브젝트이름"/"없음").</summary>
public static string LastNameSource = "";
/// <summary>보스 라벨 결정 — 테이블 이름 우선, 폴백은 `(Clone)` 을 지운 오브젝트 이름.</summary>
public static string ResolveBossLabel(Actor actor)
{
return ResolveBossLabel(actor, WL.Combat.Boss.WLBossArenaSettings.Instance);
}
/// <summary>설정을 직접 받는 오버로드(프로브가 임시 SO 로 A/B 한다 · 에셋을 건드리지 않는다).</summary>
public static string ResolveBossLabel(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena)
{
bool useTable = arena == null || arena.bossNameFromTable;
if (useTable)
{
string t = ResolveTableName(actor, arena);
if (!string.IsNullOrEmpty(t)) { LastNameSource = "테이블"; return t; }
}
if (actor == null) { LastNameSource = "없음"; return ""; }
string n = actor.name;
if (!useTable) { LastNameSource = "오브젝트이름(원본)"; return n; }
LastNameSource = "오브젝트이름";
return StripClone(n);
}
/// <summary>`Anubis(Clone)` · `Anubis (Clone)` → `Anubis`.</summary>
public static string StripClone(string n)
{
if (string.IsNullOrEmpty(n)) return n;
int i = n.IndexOf("(Clone)", System.StringComparison.Ordinal);
if (i < 0) return n;
return n.Substring(0, i).TrimEnd();
}
/// <summary>MonsterList 이름 — 액터의 스포너 표(MonsterAppear)에서 몬스터 ID 를 얻고, 없으면 SO 의 bossMonsterId.</summary>
private static string ResolveTableName(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena)
{
try
{
if (actor != null)
{
var ap = actor.Get_MonsterStatTableData(); // MobActor.cs:635 (public virtual)
if (ap != null && ap.Get_MobTypeCount() > 0)
{
var ids = ap.Get_MobIds();
if (ids != null && ids.Count > 0)
{
string byRow = BossBanner.ResolveMonsterName(ids[0]);
if (!string.IsNullOrEmpty(byRow)) return byRow;
}
}
}
if (arena != null && arena.bossMonsterId > 0)
return BossBanner.ResolveMonsterName(arena.bossMonsterId);
}
catch { } // 표 미로드(에디트 모드 등)에서 연출이 전투를 끊지 않게 — 813tj 와 같은 규약
return null;
}
/// <summary>보스를 바에 물린다. actor 가 null 이면 합성 검증 모드(최대 HP 는 fallbackMaxHp).</summary>
public string Bind(Actor actor, string displayName = null, float fallbackMaxHp = 100f)
{
var s = WLHudLayoutSettings.Instance;
if (s != null && !s.bossBarEnabled) return "bossBarEnabled=false — 미표시";
_boss = actor;
_bound = true;
_hideAt = -1f;
float max = ReadMaxHp(actor);
_maxHp = max > 0f ? max : Mathf.Max(1f, fallbackMaxHp);
float cur = ReadHp(actor);
_curHp = cur >= 0f ? cur : _maxHp;
if (nameLabel != null)
{
string n = displayName;
if (string.IsNullOrEmpty(n)) n = ResolveBossLabel(actor);
nameLabel.text = n;
nameLabel.gameObject.SetActive(showName && !string.IsNullOrEmpty(n));
}
ApplyLayout();
ShowNow();
SetRatio(_maxHp > 0f ? _curHp / _maxHp : 0f);
return "bound name=" + (nameLabel != null ? nameLabel.text : "-") +
" maxHp=" + _maxHp.ToString("F0") + " ratio=" + LastRatio.ToString("F3");
}
/// <summary>바 채움 비율(0~1). 눈금은 고정이고 fill 만 움직인다.</summary>
public void SetRatio(float ratio)
{
LastRatio = Mathf.Clamp01(ratio);
if (fill != null) fill.fillAmount = LastRatio;
// WL-813q2 — 물고 있는 동안만 런 단위 값을 남긴다(숨김·리셋 뒤의 0 은 반영하지 않는다).
if (_bound) { RunBossHpRatio = LastRatio; RunBossSeen = true; }
}
private void ShowNow()
{
if (group == null) return;
group.alpha = 1f; group.blocksRaycasts = false; group.interactable = false;
}
private void HideNow()
{
_bound = false; _boss = null; _hideAt = -1f;
if (group == null) return;
group.alpha = 0f; group.blocksRaycasts = false; group.interactable = false;
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
/// <summary>없는 자식만 만든다(에디터 오소링과 런타임이 같은 코드를 쓴다). 이미 있으면 아무 것도 하지 않는다.</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
bool made = false;
if (group == null) { group = GetComponent<CanvasGroup>(); if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; } }
if (bar == null)
{
bar = NewChild(_rt, "Bar");
made = true;
}
if (background == null)
{
var bg = NewChild(bar, "Bg");
Stretch(bg);
background = bg.gameObject.AddComponent<Image>();
background.raycastTarget = false;
made = true;
}
if (fill == null)
{
var f = NewChild(bar, "Fill");
Stretch(f);
fill = f.gameObject.AddComponent<Image>();
fill.raycastTarget = false;
fill.type = Image.Type.Filled;
fill.fillMethod = Image.FillMethod.Horizontal;
fill.fillOrigin = (int)Image.OriginHorizontal.Left;
fill.fillAmount = 1f;
made = true;
}
if (tickRoot == null)
{
tickRoot = NewChild(bar, "Ticks");
Stretch(tickRoot);
made = true;
}
if (nameLabel == null)
{
var n = NewChild(_rt, "Name");
nameLabel = n.gameObject.AddComponent<TextMeshProUGUI>();
nameLabel.raycastTarget = false;
nameLabel.alignment = TextAlignmentOptions.Center;
nameLabel.text = "";
if (nameFont != null) nameLabel.font = nameFont;
made = true;
}
EnsureFillRenderable(); // WL-813c3 — 이미 저장돼 있던 노드도 여기서 고친다
return made;
}
// ── WL-813c3 (Q7 D-26 「계기는 맞고 화면은 틀리다」) ──────────────────────
// 실측: 프리팹에 저장된 `IngameUIs/WL_HUD/WL_BossHpBar/Bar/Fill` 의 Image 는
// `m_Type: 3(Filled) · m_FillMethod: 0(Horizontal) · m_FillAmount: 1` 인데
// **`m_Sprite: {fileID: 0}` = 비어 있다**. 위 BuildIfNeeded():499 가 AddComponent 만 하고
// 스프라이트를 한 번도 안 물렸기 때문이다.
// uGUI `Image.OnPopulateMesh` 는 맨 앞에서 `if (activeSprite == null) { base.OnPopulateMesh(toFill); return; }`
// 로 빠진다 — 즉 스프라이트가 없으면 `type`·`fillAmount` 를 **아예 읽지 않고** 사각형 전체를
// m_Color 로 채운다. 그래서 계기 `fillAmount=0.368` 이 정확해도 화면은 만피 빨강이었다
// (Q7 `PD4_03_bossbar.png` · `PD4_06_timeout_hp1.png` · 3칸은 그 위에 얹힌 Tick_0/Tick_1 선).
// → 바가 켜질 때마다 스프라이트와 Filled 설정을 강제한다. 값·스위치는 전부 WLBossUiSettings(C8/C45).
// 🔴 GC: Image 세터는 값이 같으면 되돌아가므로 재적용 비용 0. 흰 스프라이트는 **정적 1개**만 만든다.
private static Sprite s_whiteSprite;
/// <summary>내장 흰 텍스처로 만든 공유 스프라이트(할당 1회 · 에셋 저장 안 됨).</summary>
public static Sprite WhiteSprite
{
get
{
if (s_whiteSprite == null)
{
var tex = Texture2D.whiteTexture;
s_whiteSprite = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height),
new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect);
s_whiteSprite.name = "WL_BossBarFill_White";
s_whiteSprite.hideFlags = HideFlags.HideAndDontSave; // 프리팹/씬에 굳지 않는다
}
return s_whiteSprite;
}
}
/// <summary>fill 이 실제로 잘려 그려지도록 스프라이트·Filled 설정을 강제한다(D-26 본체 수리).</summary>
public string EnsureFillRenderable()
{
if (fill == null) return "fill 없음 — BuildIfNeeded 먼저";
var b = WLBossUiSettings.Instance;
if (b == null) return "WLBossUiSettings 에셋 없음 — 건너뜀";
if (!b.bossBarForceFilledFill) return "bossBarForceFilledFill=false — 건너뜀(813c2 상태)";
fill.type = Image.Type.Filled;
fill.fillMethod = Image.FillMethod.Horizontal;
fill.fillOrigin = (int)Image.OriginHorizontal.Left;
fill.fillClockwise = true;
fill.preserveAspect = false; // 바는 rect 를 꽉 채운다(스프라이트 비율 무시)
fill.raycastTarget = false;
Sprite sp = b.bossBarFillSprite;
if (sp == null && b.bossBarFillWhiteFallback) sp = WhiteSprite;
if (sp != null && fill.sprite != sp) fill.sprite = sp;
if (b.bossBarSpriteOnBgAndTicks && sp != null)
{
if (background != null && background.sprite != sp) background.sprite = sp;
if (tickRoot != null)
for (int i = 0; i < tickRoot.childCount; i++)
{
var img = tickRoot.GetChild(i).GetComponent<Image>();
if (img != null && img.sprite != sp) img.sprite = sp;
}
}
return "fill type=" + fill.type + " method=" + fill.fillMethod + " origin=" + fill.fillOrigin +
" sprite=" + (fill.sprite != null ? fill.sprite.name : "(없음)") +
" amount=" + fill.fillAmount.ToString("F3");
}
// ── WL-813c3 — 중복 노드 차단 ─────────────────────────────────────────
// 발주서 후보 ①(바가 2벌이라 안 보이는 쪽만 갱신) 검증용. 실측상 프리팹에는 1벌뿐이지만,
// 프로브·에디터 도구가 런타임에 하나 더 만들면 뒤쪽 만피 바가 그대로 보인다 → 첫 1개만 남긴다.
private static readonly System.Collections.Generic.List<BossHpBar> s_barBuf =
new System.Collections.Generic.List<BossHpBar>(4);
/// <summary>같은 캔버스(없으면 계층 루트) 아래에서 발견한 BossHpBar 개수(진단).</summary>
public int BarInstanceCount { get; private set; }
/// <summary>중복이라 이 인스턴스가 숨겨졌는가(진단).</summary>
public bool DedupHidden { get; private set; }
/// <summary>중복 BossHpBar 를 첫 1개만 남기고 숨긴다. 돌려주는 값 = 진단 문자열.</summary>
public string DedupInstances()
{
var b = WLBossUiSettings.Instance;
if (b == null) return "중복차단: WLBossUiSettings 에셋 없음 — 생략";
if (!b.bossBarDedupInstances) return "중복차단: bossBarDedupInstances=false — 생략";
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
Transform scope = _canvas != null ? _canvas.transform : transform.root;
if (scope == null) return "중복차단: 범위 없음 — 생략";
s_barBuf.Clear();
scope.GetComponentsInChildren(true, s_barBuf);
BarInstanceCount = s_barBuf.Count;
if (BarInstanceCount <= 1) { DedupHidden = false; return "중복차단: 1벌 — 조치 없음"; }
BossHpBar keep = s_barBuf[0];
int hidden = 0;
for (int i = 1; i < s_barBuf.Count; i++)
{
var extra = s_barBuf[i];
if (extra == null || ReferenceEquals(extra, keep)) continue;
extra.DedupHidden = true;
extra.HideNow();
hidden++;
}
DedupHidden = !ReferenceEquals(this, keep);
return "중복차단: " + BarInstanceCount + "벌 발견 → 유지 " + keep.name + " · 숨김 " + hidden + "벌";
}
/// <summary>설정 값(px)을 캔버스 유닛으로 환산해 바·눈금·색을 맞춘다.</summary>
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLHudLayoutSettings.Instance;
if (s == null) return "WLHudLayoutSettings 에셋 없음 — 배치 건너뜀";
if (bar == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = ComputeUnitsPerPx(s);
// 루트 = Safe Area 상단 가로 스트레치 (부모는 SafeAreaFitter 가 붙은 WL_HUD)
_rt.anchorMin = new Vector2(0.5f, 1f);
_rt.anchorMax = new Vector2(0.5f, 1f);
_rt.pivot = new Vector2(0.5f, 1f);
_rt.anchoredPosition = new Vector2(0f, -s.bossBarTopMarginPx * u);
float nameH = s.bossBarNameFontPx + s.bossBarNameGapPx;
_rt.sizeDelta = new Vector2(s.bossBarSizePx.x * u, (s.bossBarSizePx.y + nameH) * u);
_rt.localScale = Vector3.one;
if (nameLabel != null)
{
var nrt = nameLabel.rectTransform;
nrt.anchorMin = new Vector2(0f, 1f); nrt.anchorMax = new Vector2(1f, 1f);
nrt.pivot = new Vector2(0.5f, 1f);
nrt.offsetMin = new Vector2(0f, 0f); nrt.offsetMax = new Vector2(0f, 0f);
nrt.anchoredPosition = Vector2.zero;
nrt.sizeDelta = new Vector2(0f, s.bossBarNameFontPx * u);
nameLabel.fontSize = s.bossBarNameFontPx * u;
nameLabel.color = s.bossBarNameColor;
if (nameFont != null && nameLabel.font != nameFont) nameLabel.font = nameFont;
}
bar.anchorMin = new Vector2(0f, 0f); bar.anchorMax = new Vector2(1f, 0f);
bar.pivot = new Vector2(0.5f, 0f);
bar.anchoredPosition = Vector2.zero;
bar.sizeDelta = new Vector2(0f, s.bossBarSizePx.y * u);
if (background != null) background.color = s.bossBarBgColor;
if (fill != null) fill.color = s.bossBarFillColor;
RebuildTicks(s, u);
// WL-813c3 — 눈금을 다시 만든 뒤에 렌더 설정을 강제한다(새 Tick 에도 적용되도록 순서가 중요).
string render = EnsureFillRenderable();
return "layout size=" + _rt.sizeDelta + " unitsPerPx=" + u.ToString("F4") +
" ticks=" + (s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0) + " · " + render;
}
private void RebuildTicks(WLHudLayoutSettings s, float u)
{
if (tickRoot == null) return;
int want = s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0;
// 남는 눈금은 지운다(설정에서 개수를 줄였을 때).
for (int i = tickRoot.childCount - 1; i >= want; i--)
{
var extra = tickRoot.GetChild(i).gameObject;
if (Application.isPlaying) Destroy(extra); else DestroyImmediate(extra);
}
for (int i = tickRoot.childCount; i < want; i++)
{
var t = NewChild(tickRoot, "Tick_" + i);
var img = t.gameObject.AddComponent<Image>();
img.raycastTarget = false;
}
for (int i = 0; i < want; i++)
{
var t = tickRoot.GetChild(i) as RectTransform;
if (t == null) continue;
float at = Mathf.Clamp01(s.bossPhaseTicks[i]);
t.anchorMin = new Vector2(at, 0f);
t.anchorMax = new Vector2(at, 1f);
t.pivot = new Vector2(0.5f, 0.5f);
t.anchoredPosition = Vector2.zero;
t.sizeDelta = new Vector2(s.bossBarTickWidthPx * u, 0f);
t.localScale = Vector3.one;
var img = t.GetComponent<Image>();
if (img != null) img.color = s.bossBarTickColor;
}
}
/// <summary>px → 캔버스 유닛 계수. 산식은 WLHudLayoutSettings 가 갖는다(전투 패드와 같은 것을 쓴다).</summary>
public float ComputeUnitsPerPx(WLHudLayoutSettings s)
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
return s.UnitsPerPx(_canvas);
}
private static RectTransform NewChild(RectTransform parent, string childName)
{
var go = new GameObject(childName, typeof(RectTransform));
go.layer = parent != null ? parent.gameObject.layer : 5;
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
rt.localScale = Vector3.one;
return rt;
}
private static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
rt.pivot = new Vector2(0.5f, 0.5f);
}
/// <summary>에디터가 폰트를 주입한다(런타임 로드 없음).</summary>
public void SetNameFont(TMP_FontAsset font)
{
nameFont = font;
if (nameLabel != null && font != null) nameLabel.font = font;
}
// ── 검증 전용 (발주서 ⓒ) ─────────────────────────────────────────────
/// <summary>
/// 가짜 전투 이벤트를 **실제 CombatEvents 경로로** 발행한다(Assembly-CSharp 내부라 Dispatch 접근 가능).
/// Play 없이 구독 → 갱신을 확인하는 용도. actor 가 없으므로 페이로드의 actor/victim 은 null 이다.
/// </summary>
public static float DebugFallbackMaxHp = 100f; // 합성 이벤트 전용(actor 가 없을 때의 최대 HP)
public static string RaiseFakeBossSpawned(float maxHp)
{
DebugFallbackMaxHp = Mathf.Max(1f, maxHp);
var e = new SpawnedEvent { actor = null, isBoss = true, isElite = false, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Spawned.Dispatch(in e);
return "dispatched Spawned(isBoss=true) subscribers=" + CombatEvents.Spawned.Count;
}
/// <summary>비보스 스폰 — 바가 반응하지 않아야 한다(회귀 확인).</summary>
public static string RaiseFakeNonBossSpawned()
{
var e = new SpawnedEvent { actor = null, isBoss = false, isElite = false, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Spawned.Dispatch(in e);
return "dispatched Spawned(isBoss=false) subscribers=" + CombatEvents.Spawned.Count;
}
public static string RaiseFakeHit(double damage)
{
var e = new HitConfirmedEvent { victim = null, attacker = null, dinfo = null, damage = damage, critical = false, isKill = false, byMainPC = true, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.HitConfirmed.Dispatch(in e);
return "dispatched HitConfirmed(dmg=" + damage + ") subscribers=" + CombatEvents.HitConfirmed.Count;
}
public static string RaiseFakeKilled()
{
var e = new KilledEvent { victim = null, killer = null, id = 0, position = Vector3.zero, byDirectHit = true, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Killed.Dispatch(in e);
return "dispatched Killed subscribers=" + CombatEvents.Killed.Count;
}
/// <summary>현재 상태 덤프. 발주서 ⓒ 증거용.</summary>
public string Dump()
{
var sb = new StringBuilder();
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLHudLayoutSettings.Instance;
sb.AppendLine("[BossHpBar] subscribed=" + _subscribed +
" visible=" + Visible + " ratio=" + LastRatio.ToString("F3") +
" hp=" + _curHp.ToString("F0") + "/" + _maxHp.ToString("F0") +
" seen(Spawned/Hit/Killed)=" + SpawnedSeen + "/" + HitSeen + "/" + KilledSeen +
" 늦은구독=" + LateBound + "(스캔 " + LateScanCount + " · BossArena.Boss=" +
(WL.Combat.Boss.BossArena.Boss != null ? WL.Combat.Boss.BossArena.Boss.name : "없음") + ")");
sb.AppendLine(" [813c2] 폴링 " + PollCount + "회 · MaxHP 보정 " + MaxHpFixCount + "회 · 아레나 재바인드 " +
PollRebindCount + "회 · hp=" + PolledHp.ToString("F0") + "/" + PolledMaxHp.ToString("F0") +
" · BossArena.BossSpawned=" + WL.Combat.Boss.BossArena.BossSpawned +
" · 설정 " + (s == null ? "(에셋 없음)" :
"poll=" + s.bossBarPollEnabled + "@" + s.bossBarPollHz.ToString("F0") + "Hz" +
" 아레나추종=" + s.bossBarPollFollowsArena +
" 사망숨김=" + s.bossBarPollHidesOnDeath +
" 재바인드조건=" + s.bossBarRebindRequiresArenaSpawn));
sb.AppendLine(" [813q2] 런 리셋 " + RunResetCount + "회 · 런 보스 HP=" +
(RunBossHpRatio >= 0f ? (RunBossHpRatio * 100f).ToString("F1") + "%" : "없음") +
" (본적=" + RunBossSeen + ") · RunStarted 구독=" + RunEvents.RunStarted.Count);
sb.AppendLine(" [813w8] 게이트전숨김=" + SuppressedBeforeGate +
"(무장=" + WL.Combat.Boss.BossArena.GateArmed +
" 개방=" + WL.Combat.Boss.BossArena.GateOpen + "/" + RunDirector.GateOpened + ")" +
" · 거절 " + GateSuppressCount + "회 · 내림 " + GateHideCount + "회 · 개방바인드 " +
GateOpenBinds + "회(" + (string.IsNullOrEmpty(LastGateOpenBind) ? "-" : LastGateOpenBind) + ")" +
" · BossGateOpened 구독=" + RunEvents.BossGateOpened.Count);
sb.AppendLine(" CombatEvents subscribers Spawned=" + CombatEvents.Spawned.Count +
" HitConfirmed=" + CombatEvents.HitConfirmed.Count +
" Killed=" + CombatEvents.Killed.Count + " Enabled=" + CombatEvents.Enabled);
sb.AppendLine(" root " + (_rt != null ? "pos" + _rt.anchoredPosition + " size" + _rt.sizeDelta + " aMin" + _rt.anchorMin + " aMax" + _rt.anchorMax : "(없음)"));
sb.AppendLine(" bar " + (bar != null ? "size" + bar.sizeDelta + " aMin" + bar.anchorMin + " aMax" + bar.anchorMax : "(없음)") +
" fill=" + (fill != null ? fill.fillAmount.ToString("F3") : "-") +
" ticks=" + (tickRoot != null ? tickRoot.childCount : 0));
// ── WL-813c3 — D-26 「화면」 진단: fillAmount 가 실제 메쉬에 반영되는 조건 ──
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
var bset = WLBossUiSettings.Instance;
sb.AppendLine(" [813c3] fill " + (fill == null ? "(없음)" :
"type=" + fill.type + " method=" + fill.fillMethod + " origin=" + fill.fillOrigin +
" amount=" + fill.fillAmount.ToString("F3") +
" sprite=" + (fill.sprite != null ? fill.sprite.name : "🔴(없음)=fillAmount 무효") +
" enabled=" + fill.enabled + " color=" + ColorUtility.ToHtmlStringRGBA(fill.color) +
" active=" + fill.gameObject.activeInHierarchy) +
" · 바 " + BarInstanceCount + "벌(이 인스턴스 숨김=" + DedupHidden + ")" +
" · HP0숨김 " + ZeroHpHideCount + "회" +
" · 설정 " + (bset == null ? "(에셋 없음)" :
"force=" + bset.bossBarForceFilledFill + " white=" + bset.bossBarFillWhiteFallback +
" spriteSO=" + (bset.bossBarFillSprite != null ? bset.bossBarFillSprite.name : "없음") +
" dedup=" + bset.bossBarDedupInstances + " hp0숨김=" + bset.bossBarHideOnZeroHp));
sb.AppendLine(" [813c3] canvas " + (_canvas == null ? "(없음)" :
_canvas.name + " order=" + _canvas.sortingOrder + " override=" + _canvas.overrideSorting +
" enabled=" + _canvas.enabled + " renderMode=" + _canvas.renderMode));
if (tickRoot != null)
for (int i = 0; i < tickRoot.childCount; i++)
{
var t = tickRoot.GetChild(i) as RectTransform;
if (t != null) sb.AppendLine(" " + t.name + " at=" + t.anchorMin.x.ToString("F3") + " w=" + t.sizeDelta.x.ToString("F1"));
}
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"sizePx=" + s.bossBarSizePx + " topMarginPx=" + s.bossBarTopMarginPx +
" ticks=" + (s.bossPhaseTicks != null ? string.Join(",", System.Array.ConvertAll(s.bossPhaseTicks, v => v.ToString("F2"))) : "-")));
return sb.ToString();
}
}
}