597 lines
29 KiB
C#
597 lines
29 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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();
|
||
}
|
||
}
|
||
}
|