// ───────────────────────────────────────────────────────────────────────────── // 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(); } private void OnEnable() { Initialize(); } private void OnDisable() { Subscribe(false); } /// OnEnable 본체 — 에디트 모드 검증이 런타임과 같은 경로를 타도록 분리했다(813c 교훈). public string Initialize() { if (_rt == null) _rt = GetComponent(); 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; } /// /// 배너가 켜질 때 **이미 살아 있는 보스**가 있으면 배너를 띄운다 (WL-813y). /// 필드 보스는 맵 로드 시(`FieldBossData.Start()`) 스폰돼 `Spawned(isBoss)` 가 구독 전에 지나간다 /// → 813c 보스 HP 바(`BossHpBar.BindExistingBoss`)와 **같은 조회**를 쓴다. /// 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(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 " + 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; } /// 늦은 구독 보정으로 배너를 띄웠는가(진단). public bool LateTriggered { get; private set; } /// 마지막 씬 스캔에서 본 MobActor 수(진단). 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(); } /// 런 시작 리셋 — 배너를 숨기고 재트리거 가드·이름을 지운다(같은 보스라도 새 런에서는 다시 뜬다). 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 "런시작리셋: 숨김·가드 해제만"; // WL-813c2 (Q6 D-23 잔여) — 813c 바와 같은 조건. `BossArena.ResetGate()`(`BossArena.cs:630`)는 // `s_boss` 를 비우지 않아, 조건이 없으면 **이전 런의 살아 있는 보스**로 배너가 다시 뜬다. var hud = WLHudLayoutSettings.Instance; if (hud != null && hud.bossBarRebindRequiresArenaSpawn && !WL.Combat.Boss.BossArena.BossSpawned) return "런시작리셋: 숨김만(이번 런 보스 미등장 · BossArena.BossSpawned=false)"; return "런시작리셋 → " + TriggerExistingBoss(); } /// 런 시작 리셋 횟수(진단 · D-23). 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)); } /// MonsterList.n_MonsterName → localtext. 표가 없으면(에디트 모드) null. 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; } // 표 미로드 상태에서 연출이 전투를 끊지 않게 } /// /// 배너를 연다. 이미 표시 중이면 이름만 승격한다(같은 스폰에서 Spawned·BossPhase 가 둘 다 오는 경우). /// 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); } /// 배너·비네트 진행. 에디트 모드 프로브가 시간을 넣어 같은 경로로 검증한다. 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)); } } } /// 0→1→0 맥동. pulses 회 반복하고, 첫 상승만 fadeIn 시간을 따른다. 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; } /// 프로브 전용 — 카운터와 재트리거 가드를 지운다(에디트 모드는 시간이 흐르지 않는다). 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; } // ── 구성 · 배치 ─────────────────────────────────────────────────────── /// 없는 자식만 만든다(에디터 오소링과 런타임이 같은 코드를 쓴다). public bool BuildIfNeeded() { if (_rt == null) _rt = GetComponent(); bool made = false; if (group == null) { group = GetComponent(); if (group == null) { group = gameObject.AddComponent(); 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(); if (bannerBg == null) bannerBg = bg.gameObject.AddComponent(); bannerBg.raycastTarget = false; made = true; } if (titleLabel == null) { var t = WLVignetteUtil.NewChild(bannerBox, "Title"); titleLabel = t.GetComponent(); if (titleLabel == null) titleLabel = t.gameObject.AddComponent(); 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(); if (nameLabel == null) nameLabel = t.gameObject.AddComponent(); 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; } /// 설정 값(px)을 캔버스 유닛으로 환산해 배너·비네트를 맞춘다. public string ApplyLayout() { if (_rt == null) _rt = GetComponent(); 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(true); return WLVignetteUtil.UnitsPerPx(_canvas); } /// 에디터가 폰트를 주입한다(런타임 로드 없음). public void SetFont(TMP_FontAsset f) { font = f; if (f == null) return; if (titleLabel != null) titleLabel.font = f; if (nameLabel != null) nameLabel.font = f; } // ── 검증 전용 (발주서 ⓑ) ───────────────────────────────────────────── /// 가짜 보스 스폰을 **실제 CombatEvents 경로로** 발행한다(Play 0 검증용). 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; } /// 가짜 보스 페이즈 1 진입(813h 경로) — 이름 승격 검증용. 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(); } } }