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

519 lines
26 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// BossBanner.cs — 보스 등장 배너 + 경고 비네트 (WL-813tj · 813t · #813)
//
// 기준서 v1 §D-1 813t 행 / §B 요소 6 "등장 · 처치 연출":
// Spawned(isBoss) → 상단 "BOSS" 배너 1.2 s + 화면 가장자리 붉은 UI 비네트 0.8 s
// (포스트 프로세싱 금지 → UI 이미지) · 보스 이름 = MonsterList.n_MonsterName · 811d 오빗과 동시.
//
// ■ 이벤트 (구독만 · 발행 0)
// ① WL.Combat.Core.CombatEvents.Spawned(isBoss) — 813c 보스 HP 바와 **같은 디스패치**를 탄다
// → 같은 프레임에 배너와 바가 함께 뜬다(구독 순서와 무관 · 같은 Dispatch 안).
// ② WL.Combat.Boss.BossEvents.BossPhase(isSpawn) — 813h 가 내는 페이즈 1 진입.
// 이쪽만 **monsterId** 를 준다 → 이름은 여기서 나온다. 먼저 온 쪽이 배너를 열고,
// 뒤늦게 이름이 오면 표시 중인 배너의 이름 줄만 갱신한다(중복 표시 없음).
// 이벤트가 없으면 아무 것도 뜨지 않는다 — 폴링 폴백을 두지 않는다(813c 규칙).
//
// ■ 값 = WLBossUiSettings.asset (C45) · px→유닛 = 813c WLHudLayoutSettings.UnitsPerPx 하나만.
// ■ 숨김 = CanvasGroup alpha (GameObject 를 끄면 OnEnable 이 안 돌아 구독이 끊긴다 · 813c 규칙)
// ■ 시간 = Time.unscaledTime (811b 히트스톱·킬캠 슬로모 중에도 배너 길이가 흔들리지 않게)
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Boss;
using WL.Combat.Core;
using WL.Combat.Run;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class BossBanner : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform bannerBox;
[SerializeField] private Image bannerBg;
[SerializeField] private TextMeshProUGUI titleLabel;
[SerializeField] private TextMeshProUGUI nameLabel;
[SerializeField] private RectTransform warnRoot;
[Header("폰트 (에디터가 주입)")]
[SerializeField] private TMP_FontAsset font;
private RectTransform _rt;
private Canvas _canvas;
private WLEdgeVignette _warn;
private bool _subscribed;
private float _bannerStart = -1f; // 배너 시작 unscaledTime (-1 = 꺼짐)
private float _warnStart = -1f;
private float _lastTrigger = -999f;
private string _shownName = "";
private bool _nameKnown;
// ── 진단(프로브가 읽는다) ────────────────────────────────────────────
public int SpawnedSeen { get; private set; }
public int BossSpawnSeen { get; private set; }
public int PhaseSeen { get; private set; }
public int ShowCount { get; private set; }
public string LastName { get { return _shownName; } }
public bool Visible { get { return group != null && group.alpha > 0.001f; } }
public float BannerAlpha { get { return group != null ? group.alpha : 0f; } }
public float WarnAlpha { get { return _warn != null ? _warn.CurrentAlpha : 0f; } }
public float BannerScale { get { return bannerBox != null ? bannerBox.localScale.x : 0f; } }
private void Awake() { _rt = GetComponent<RectTransform>(); }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// <summary>OnEnable 본체 — 에디트 모드 검증이 런타임과 같은 경로를 타도록 분리했다(813c 교훈).</summary>
public string Initialize()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
BuildIfNeeded();
string layout = ApplyLayout();
HideNow();
var s = WLBossUiSettings.Instance;
Subscribe(s == null || s.bannerEnabled);
string late = TriggerExistingBoss(); // WL-813y — 늦은 구독 보정(Q3 결함 D-3 · 813c 보스 바와 동일)
return "initialized subscribed=" + _subscribed + " · " + layout + " · " + late;
}
/// <summary>
/// 배너가 켜질 때 **이미 살아 있는 보스**가 있으면 배너를 띄운다 (WL-813y).
/// 필드 보스는 맵 로드 시(`FieldBossData.Start()`) 스폰돼 `Spawned(isBoss)` 가 구독 전에 지나간다
/// → 813c 보스 HP 바(`BossHpBar.BindExistingBoss`)와 **같은 조회**를 쓴다.
/// </summary>
public string TriggerExistingBoss()
{
var s = WLBossUiSettings.Instance;
if (s != null && !s.bannerEnabled) return "늦은구독: bannerEnabled=false — 생략";
if (s != null && !s.bannerTriggerExisting) return "늦은구독: bannerTriggerExisting=false — 생략";
Actor found = null;
string how = "";
var arenaBoss = WL.Combat.Boss.BossArena.Boss;
if (IsAliveBoss(arenaBoss)) { found = arenaBoss; how = "BossArena.Boss"; }
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 + ")";
LateTriggered = true;
// 이름은 813h BossPhase 가 오면 승격된다(813tj 실측) — 여기서는 오브젝트 이름으로 먼저 띄운다.
return "늦은구독 " + how + " → " + Trigger(found.name, false);
}
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 LateTriggered { get; private set; }
/// <summary>마지막 씬 스캔에서 본 MobActor 수(진단).</summary>
public int LateScanCount { get; private set; }
public void Subscribe(bool on)
{
if (on == _subscribed) return;
_subscribed = on;
if (on)
{
CombatEvents.Spawned.Add(OnSpawned);
BossEvents.BossPhase.Add(OnBossPhase);
RunEvents.RunStarted.Add(OnRunStarted); // WL-813q2(D-23) — 다음 런 리셋
}
else
{
CombatEvents.Spawned.Remove(OnSpawned);
BossEvents.BossPhase.Remove(OnBossPhase);
RunEvents.RunStarted.Remove(OnRunStarted);
}
}
// ── WL-813q2(D-23) — 「다음 런」 뒤 배너 상태(재트리거 가드·이름) 리셋 ──
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++;
_lastTrigger = -999f; _shownName = ""; _nameKnown = false;
LateTriggered = false;
HideNow();
if (ui == null || !ui.bossBannerRetriggerOnRunStart) return "런시작리셋: 숨김·가드 해제만";
return "런시작리셋 → " + TriggerExistingBoss();
}
/// <summary>런 시작 리셋 횟수(진단 · D-23).</summary>
public static int RunResetCount;
// ── 구독 ──────────────────────────────────────────────────────────────
private void OnSpawned(in SpawnedEvent e)
{
SpawnedSeen++;
if (!e.isBoss) return;
BossSpawnSeen++;
string n = e.actor != null ? e.actor.name : null;
Trigger(n, false);
}
private void OnBossPhase(in BossPhaseEvent e)
{
PhaseSeen++;
if (!e.isSpawn) return;
string n = ResolveMonsterName(e.monsterId);
Trigger(n, !string.IsNullOrEmpty(n));
}
/// <summary>MonsterList.n_MonsterName → localtext. 표가 없으면(에디트 모드) null.</summary>
public static string ResolveMonsterName(int monsterId)
{
if (monsterId <= 0) return null;
try
{
if (table_monsterlist.Ins == null) return null;
var d = table_monsterlist.Ins.Get_Data_orNull(monsterId);
if (d == null) return null;
if (table_localtext.Ins == null) return null;
return d.Get_Name();
}
catch { return null; } // 표 미로드 상태에서 연출이 전투를 끊지 않게
}
/// <summary>
/// 배너를 연다. 이미 표시 중이면 이름만 승격한다(같은 스폰에서 Spawned·BossPhase 가 둘 다 오는 경우).
/// </summary>
public string Trigger(string bossName, bool nameIsAuthoritative)
{
var s = WLBossUiSettings.Instance;
if (s == null) return "WLBossUiSettings 에셋 없음 — 표시 안 함";
if (!s.bannerEnabled) return "bannerEnabled=false — 표시 안 함";
float now = Time.unscaledTime;
bool showing = _bannerStart >= 0f && now - _bannerStart < s.bannerSeconds;
if (showing || now - _lastTrigger < s.retriggerGuardSeconds)
{
// 중복 트리거 — 더 정확한 이름이 왔을 때만 갱신한다.
if (nameIsAuthoritative && !string.IsNullOrEmpty(bossName)) SetName(bossName, true, s);
return "재트리거 억제(guard " + s.retriggerGuardSeconds.ToString("F1") + "s) name=" + _shownName;
}
_lastTrigger = now;
_bannerStart = now;
_warnStart = s.warnVignetteEnabled ? now : -1f;
ShowCount++;
SetName(bossName, nameIsAuthoritative, s);
ApplyLayout();
Tick(now);
if (s.verboseLog) Debug.Log("[BossBanner] show name=" + _shownName + " banner=" + s.bannerSeconds + "s warn=" + s.warnVignetteSeconds + "s");
return "shown name=" + (string.IsNullOrEmpty(_shownName) ? "(없음)" : _shownName) +
" banner=" + s.bannerSeconds.ToString("F2") + "s warn=" + (_warnStart >= 0f ? s.warnVignetteSeconds.ToString("F2") + "s" : "off");
}
private void SetName(string bossName, bool authoritative, WLBossUiSettings s)
{
if (authoritative || !_nameKnown)
{
_shownName = bossName ?? "";
_nameKnown = authoritative;
}
if (titleLabel != null) titleLabel.text = s.bannerTitleText;
if (nameLabel != null)
{
nameLabel.text = _shownName;
bool show = !(s.hideNameWhenUnknown && string.IsNullOrEmpty(_shownName));
if (nameLabel.gameObject.activeSelf != show) nameLabel.gameObject.SetActive(show);
}
}
private void Update() { Tick(Time.unscaledTime); }
/// <summary>배너·비네트 진행. 에디트 모드 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
public void Tick(float now)
{
var s = WLBossUiSettings.Instance;
if (s == null) return;
// ── 배너
if (_bannerStart >= 0f)
{
float t = now - _bannerStart;
if (t >= s.bannerSeconds) { _bannerStart = -1f; SetBannerAlpha(0f); }
else
{
float a = 1f;
if (s.bannerFadeInSeconds > 0f && t < s.bannerFadeInSeconds) a = t / s.bannerFadeInSeconds;
float outStart = s.bannerSeconds - s.bannerFadeOutSeconds;
if (s.bannerFadeOutSeconds > 0f && t > outStart) a = Mathf.Min(a, (s.bannerSeconds - t) / s.bannerFadeOutSeconds);
SetBannerAlpha(Mathf.Clamp01(a));
// 등장 펀치 → 원래 크기로 수렴(813g 텍스트 연출과 같은 계열)
if (bannerBox != null)
{
float k = (s.bannerPunchSeconds > 0f) ? Mathf.Clamp01(t / s.bannerPunchSeconds) : 1f;
float sc = Mathf.Lerp(s.bannerPunchScale, 1f, k);
bannerBox.localScale = new Vector3(sc, sc, 1f);
}
}
}
// ── 경고 비네트
if (_warnStart >= 0f && _warn != null && _warn.IsBuilt)
{
float t = now - _warnStart;
if (t >= s.warnVignetteSeconds) { _warnStart = -1f; _warn.SetAlpha(s.warnVignetteColor, 0f); }
else
{
_warn.SetAlpha(s.warnVignetteColor, PulseCurve(t, s.warnVignetteSeconds, s.warnVignetteFadeInSeconds, s.warnVignettePulses));
}
}
}
/// <summary>0→1→0 맥동. pulses 회 반복하고, 첫 상승만 fadeIn 시간을 따른다.</summary>
public static float PulseCurve(float t, float total, float fadeIn, int pulses)
{
if (total <= 0f) return 0f;
int n = Mathf.Max(1, pulses);
float seg = total / n;
float local = t - Mathf.Floor(t / seg) * seg;
float up = (t < seg && fadeIn > 0f) ? Mathf.Min(fadeIn, seg * 0.5f) : seg * 0.5f;
if (local < up) return Mathf.Clamp01(local / up);
float down = seg - up;
return down <= 0f ? 0f : Mathf.Clamp01(1f - (local - up) / down);
}
private void SetBannerAlpha(float a)
{
if (group == null) return;
group.alpha = a;
group.blocksRaycasts = false;
group.interactable = false;
}
/// <summary>프로브 전용 — 카운터와 재트리거 가드를 지운다(에디트 모드는 시간이 흐르지 않는다).</summary>
public string ResetDiagnostics()
{
SpawnedSeen = BossSpawnSeen = PhaseSeen = ShowCount = 0;
_lastTrigger = -999f; _shownName = ""; _nameKnown = false;
HideNow();
return "진단 초기화(가드 해제)";
}
public void HideNow()
{
_bannerStart = -1f; _warnStart = -1f;
SetBannerAlpha(0f);
var s = WLBossUiSettings.Instance;
if (_warn != null && _warn.IsBuilt) _warn.SetAlpha(s != null ? s.warnVignetteColor : Color.red, 0f);
if (bannerBox != null) bannerBox.localScale = Vector3.one;
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
/// <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 (bannerBox == null) { bannerBox = WLVignetteUtil.NewChild(_rt, "Banner"); made = true; }
if (bannerBg == null)
{
var bg = WLVignetteUtil.NewChild(bannerBox, "Bg");
WLVignetteUtil.Stretch(bg);
bannerBg = bg.GetComponent<Image>();
if (bannerBg == null) bannerBg = bg.gameObject.AddComponent<Image>();
bannerBg.raycastTarget = false;
made = true;
}
if (titleLabel == null)
{
var t = WLVignetteUtil.NewChild(bannerBox, "Title");
titleLabel = t.GetComponent<TextMeshProUGUI>();
if (titleLabel == null) titleLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
titleLabel.raycastTarget = false;
titleLabel.alignment = TextAlignmentOptions.Center;
if (font != null) titleLabel.font = font;
made = true;
}
if (nameLabel == null)
{
var t = WLVignetteUtil.NewChild(bannerBox, "Name");
nameLabel = t.GetComponent<TextMeshProUGUI>();
if (nameLabel == null) nameLabel = t.gameObject.AddComponent<TextMeshProUGUI>();
nameLabel.raycastTarget = false;
nameLabel.alignment = TextAlignmentOptions.Center;
if (font != null) nameLabel.font = font;
made = true;
}
_warn = WLVignetteUtil.Build(_rt, "Warn");
if (warnRoot == null) { warnRoot = _warn.root; made = true; }
return made;
}
/// <summary>설정 값(px)을 캔버스 유닛으로 환산해 배너·비네트를 맞춘다.</summary>
public string ApplyLayout()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
var s = WLBossUiSettings.Instance;
if (s == null) return "WLBossUiSettings 에셋 없음 — 배치 건너뜀";
if (bannerBox == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = UnitsPerPx();
// 루트 = Safe Area 전체(부모 = SafeAreaFitter 가 붙은 WL_HUD) → 비네트가 안전 영역 가장자리를 탄다
WLVignetteUtil.Stretch(_rt);
_rt.localScale = Vector3.one;
// 배너 상자 = 상단 가운데
bannerBox.anchorMin = new Vector2(0.5f, 1f);
bannerBox.anchorMax = new Vector2(0.5f, 1f);
bannerBox.pivot = new Vector2(0.5f, 1f);
bannerBox.anchoredPosition = new Vector2(0f, -s.bannerTopMarginPx * u);
bannerBox.sizeDelta = new Vector2(s.bannerSizePx.x * u, s.bannerSizePx.y * u);
if (bannerBg != null)
{
bannerBg.color = s.bannerBgColor;
bannerBg.enabled = s.bannerBgColor.a > 0.001f;
}
float titleH = s.bannerTitleFontPx * u;
float nameH = s.bannerNameFontPx * u;
if (titleLabel != null)
{
var t = titleLabel.rectTransform;
t.anchorMin = new Vector2(0f, 1f); t.anchorMax = new Vector2(1f, 1f); t.pivot = new Vector2(0.5f, 1f);
t.offsetMin = new Vector2(0f, 0f); t.offsetMax = new Vector2(0f, 0f);
t.anchoredPosition = Vector2.zero;
t.sizeDelta = new Vector2(0f, titleH);
titleLabel.fontSize = titleH;
titleLabel.color = s.bannerTitleColor;
titleLabel.text = s.bannerTitleText;
if (font != null && titleLabel.font != font) titleLabel.font = font;
}
if (nameLabel != null)
{
var t = nameLabel.rectTransform;
t.anchorMin = new Vector2(0f, 1f); t.anchorMax = new Vector2(1f, 1f); t.pivot = new Vector2(0.5f, 1f);
t.offsetMin = new Vector2(0f, 0f); t.offsetMax = new Vector2(0f, 0f);
t.anchoredPosition = new Vector2(0f, -titleH);
t.sizeDelta = new Vector2(0f, nameH);
nameLabel.fontSize = nameH;
nameLabel.color = s.bannerNameColor;
if (font != null && nameLabel.font != font) nameLabel.font = font;
}
if (_warn == null || !_warn.IsBuilt) _warn = WLVignetteUtil.Build(_rt, "Warn");
WLVignetteUtil.ApplyLayout(_warn, s.warnVignetteThicknessPx, u, s.warnVignetteColor);
if (warnRoot == null) warnRoot = _warn.root;
if (warnRoot != null) warnRoot.SetAsFirstSibling(); // 비네트는 배너 뒤에
return "layout banner pos" + bannerBox.anchoredPosition + " size" + bannerBox.sizeDelta +
" unitsPerPx=" + u.ToString("F4") + " warnThickness=" + (s.warnVignetteThicknessPx * u).ToString("F1");
}
public float UnitsPerPx()
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(true);
return WLVignetteUtil.UnitsPerPx(_canvas);
}
/// <summary>에디터가 폰트를 주입한다(런타임 로드 없음).</summary>
public void SetFont(TMP_FontAsset f)
{
font = f;
if (f == null) return;
if (titleLabel != null) titleLabel.font = f;
if (nameLabel != null) nameLabel.font = f;
}
// ── 검증 전용 (발주서 ⓑ) ─────────────────────────────────────────────
/// <summary>가짜 보스 스폰을 **실제 CombatEvents 경로로** 발행한다(Play 0 검증용).</summary>
public static string RaiseFakeBossSpawned()
{
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;
}
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;
}
/// <summary>가짜 보스 페이즈 1 진입(813h 경로) — 이름 승격 검증용.</summary>
public static string RaiseFakeBossPhaseSpawn(int monsterId)
{
var e = new BossPhaseEvent
{
boss = null, monsterId = monsterId, prevPhase = 0, phase = 1, phaseCount = 3,
hpPercent = 1f, threshold = 1f, isSpawn = true, time = Time.unscaledTime, frame = Time.frameCount
};
BossEvents.BossPhase.Dispatch(in e);
return "dispatched BossPhase(isSpawn=true id=" + monsterId + ") subscribers=" + BossEvents.BossPhase.Count;
}
public string Dump()
{
var sb = new StringBuilder();
var s = WLBossUiSettings.Instance;
sb.AppendLine("[BossBanner] subscribed=" + _subscribed + " visible=" + Visible +
" bannerAlpha=" + BannerAlpha.ToString("F3") + " warnAlpha=" + WarnAlpha.ToString("F3") +
" scale=" + BannerScale.ToString("F3") +
" name=\"" + _shownName + "\"(known=" + _nameKnown + ")" +
" seen(Spawned/Boss/Phase)=" + SpawnedSeen + "/" + BossSpawnSeen + "/" + PhaseSeen +
" shows=" + ShowCount + " 늦은구독=" + LateTriggered + "(스캔 " + LateScanCount + ")");
sb.AppendLine(" 구독자 Spawned=" + CombatEvents.Spawned.Count + " BossPhase=" + BossEvents.BossPhase.Count +
" CombatEvents.Enabled=" + CombatEvents.Enabled + " BossEvents.Enabled=" + BossEvents.Enabled);
if (bannerBox != null)
sb.AppendLine(" banner pos" + bannerBox.anchoredPosition + " size" + bannerBox.sizeDelta +
" aMin" + bannerBox.anchorMin + " title=\"" + (titleLabel != null ? titleLabel.text : "-") +
"\" fs=" + (titleLabel != null ? titleLabel.fontSize.ToString("F1") : "-") +
" nameActive=" + (nameLabel != null && nameLabel.gameObject.activeSelf));
if (_warn != null && _warn.IsBuilt)
for (int i = 0; i < _warn.strips.Length; i++)
{
var img = _warn.strips[i];
if (img == null) continue;
var r = img.rectTransform;
sb.AppendLine(" warn[" + img.name + "] size" + r.sizeDelta + " aMin" + r.anchorMin + " aMax" + r.anchorMax +
" scale" + r.localScale + " enabled=" + img.enabled + " a=" + img.color.a.ToString("F3") +
" sprite=" + (img.sprite != null ? img.sprite.name : "(없음)") + " raycast=" + img.raycastTarget);
}
sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" :
"banner=" + s.bannerSeconds + "s warn=" + s.warnVignetteSeconds + "s topPx=" + s.bannerTopMarginPx +
" sizePx=" + s.bannerSizePx + " thickPx=" + s.warnVignetteThicknessPx + " pulses=" + s.warnVignettePulses));
return sb.ToString();
}
}
}