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

597 lines
29 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 로만 숨긴다.
//
// 🔴 어셈블리 주의: 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;
/// <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();
HideNow();
Subscribe(true);
string late = BindExistingBoss(); // WL-813y — 늦은 구독 보정(Q3 결함 D-3)
return "initialized subscribed=" + _subscribed + " · " + layout + " · " + 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 "늦은구독: 이미 물림";
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) — 다음 런 리셋
}
else
{
CombatEvents.Spawned.Remove(OnSpawned);
CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
CombatEvents.Killed.Remove(OnKilled);
RunEvents.RunStarted.Remove(OnRunStarted);
}
}
// ── 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);
if (ui != null && !ui.bossBarRebindOnRunStart) return "런시작리셋: 숨김만(재바인드 off)";
return "런시작리셋 → " + BindExistingBoss();
}
private void Update()
{
if (_hideAt >= 0f && Time.unscaledTime >= _hideAt) HideNow();
}
// ── 이벤트 구독 (811b CombatEvents) ───────────────────────────────────
private void OnSpawned(in SpawnedEvent e)
{
SpawnedSeen++;
if (!e.isBoss) 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;
}
return made;
}
/// <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);
return "layout size=" + _rt.sizeDelta + " unitsPerPx=" + u.ToString("F4") +
" ticks=" + (s.bossPhaseTicks != null ? s.bossPhaseTicks.Length : 0);
}
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(" [813q2] 런 리셋 " + RunResetCount + "회 · 런 보스 HP=" +
(RunBossHpRatio >= 0f ? (RunBossHpRatio * 100f).ToString("F1") + "%" : "없음") +
" (본적=" + RunBossSeen + ") · RunStarted 구독=" + RunEvents.RunStarted.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));
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();
}
}
}