// WL-813g (#813) — 데미지 숫자 리듬화. 원본(힘민지) HUDDMGUI 에 표시 규칙만 얹는다. // 기준서 v1 §B 요소 2: 일반 0.6 s · 크리 1.0 s · 크리 1.6배 · 합산 윈도우 0.25 s · 동시 상한(숫자죽 방지). // 값은 전부 Assets/WL/UI/Settings/Resources/WL/WLCombatTextSettings.asset (C45 · 코드 상수 0). // 🔴 C8 롤백: 에셋이 없거나 textEnabled/damageRhythmEnabled = false 면 **아래 원본 상수**(0.9 s · 배율 1 · 상한 없음) // 로 그대로 돈다 — 호출부(Actor.cs:616/789/811/841)와 프리팹은 무수정이다. using System.Collections.Generic; using TMPro; using UnityEngine; public class HUDDMGUI : HUDBase { public TextMeshProUGUI[] dmgs; // ── 원본 상수(롤백 기준값) — 설정 에셋이 없을 때 이 값으로 돈다 private float moveSpeed = 1.0f; private float alphaSpeed = 0.5f; private float destroyTime = .9f; bool usedmg2; int m_Index; // ── WL-813g 상태(설정이 꺼져 있으면 전부 미사용) static readonly List s_active = new List(); /// 프로브가 읽는다 — 지금 화면에 떠 있는 숫자 수. public static int ActiveCount { get { return s_active.Count; } } /// 프로브가 읽는다 — 상한 초과로 회수한 횟수 · 합산으로 흡수한 횟수. public static int EvictedCount, MergedCount; public static void ResetDiagnostics() { EvictedCount = MergedCount = 0; } bool m_wl; // 이번 표시에 813g 규칙이 적용됐는가 bool m_crit; // 크리티컬로 표시 중인가 float m_life, m_lifeMax; // 남은/전체 표시 시간(unscaled 아님 — 원본과 같은 Time.deltaTime 계열) float m_rise, m_fadeStart, m_scale; string m_color; // 합산 판정 키(같은 색끼리만 합친다 = 피해/회복/마나 섞임 방지) double m_total; int m_hits; /// 이 인스턴스가 합산 중인 대상(합산 창이 살아 있는 동안만 유효). public Transform MergeTarget { get { return m_MergeTarget; } } Transform m_MergeTarget; float m_mergeUntil; static WL.UI.WLCombatTextSettings St { get { return WL.UI.WLCombatTextSettings.Enabled ? WL.UI.WLCombatTextSettings.Instance : null; } } /// 합산 윈도우 길이 — 데이터 SOT 는 811e(WLReactionSettings). 없으면 합산하지 않는다. static float MergeWindow { get { var r = WL.Combat.Reaction.WLReactionSettings.Instance; if (r == null || !r.enabled || !r.damageMergeEnabled) return 0f; return r.damageMergeWindowSeconds; } } public void Set(Transform _target, string TextColor, double Damage, eStat Critical) { if (NewGameUI.Ins.m_BangchiUI.isActiveAndEnabled || !OptionInfo.Ins.go_check[0].activeSelf) { Off(true); return; } SetCore(_target, TextColor, Damage, Critical); } /// /// 표시 본체 — 원본 `Set` 의 UI 게이트(방치UI · 옵션) 뒤 부분을 그대로 뺀 것. /// 게이트가 `NewGameUI`·`OptionInfo` 싱글턴을 요구해 Play 없이는 못 부르므로, /// 에디트 모드 프로브(WL-813g ⓑ)가 **런타임과 같은 코드**를 재도록 분리했다(813c 교훈). /// public void SetCore(Transform _target, string TextColor, double Damage, eStat Critical) { var st = St; bool wl = st != null && st.damageRhythmEnabled; // ── 합산 윈도우: 같은 대상 · 같은 색이면 새 숫자를 띄우지 않고 살아 있는 숫자에 더한다(기준서 §B 0.25 s) if (wl && st.damageUseMergeWindow && _target != null) { float win = MergeWindow; if (win > 0f) { for (int i = 0; i < s_active.Count; i++) { var h = s_active[i]; if (h == null || h == this) continue; if (h.m_MergeTarget != _target || h.m_color != TextColor) continue; if (Time.time > h.m_mergeUntil) continue; h.Accumulate(Damage, Critical, st); MergedCount++; Off(true); // 이 인스턴스는 쓰지 않고 풀로 돌려보낸다 return; } } } gameObject.SetActive(true); Set_Target(_target); if (m_Target != null && m_Camera != null && m_CanvasRect != null) Set_Pos(); // 대상·카메라·캔버스가 다 있을 때만(원본은 항상 있다) usedmg2 = Critical == eStat.CRI || Critical == eStat.M_CRI; m_Index = usedmg2 ? 1 : 0; dmgs[0].enabled = !usedmg2; dmgs[1].enabled = usedmg2; dmgs[0].alpha = dmgs[1].alpha = 1f; m_wl = wl; m_crit = usedmg2; m_color = TextColor; m_total = Damage; m_hits = 1; m_MergeTarget = _target; float scatter = wl ? st.damageScatterPx * (usedmg2 ? st.damageCritScatterMultiplier : 1f) : 25f; Vector3 randomPosition = new Vector3( Random.Range(-scatter, scatter), // x 축 랜덤 범위 Random.Range(-scatter, scatter)); // y 축 랜덤 범위 dmgs[0].transform.localPosition = dmgs[1].transform.localPosition = randomPosition; if (wl) { m_lifeMax = m_life = usedmg2 ? st.damageCritSeconds : st.damageNormalSeconds; m_scale = usedmg2 ? st.damageCritScale : st.damageNormalScale; m_rise = st.damageRiseSpeed * (usedmg2 ? st.damageCritRiseMultiplier : 1f); m_fadeStart = Mathf.Clamp01(st.damageFadeStartRatio); m_mergeUntil = Time.time + Mathf.Max(0f, MergeWindow); dmgs[m_Index].transform.localScale = Vector3.one * m_scale; OffTime = m_lifeMax; Register(st); } else { m_lifeMax = m_life = destroyTime; m_scale = 1f; m_rise = moveSpeed; m_fadeStart = 0f; m_mergeUntil = 0f; dmgs[0].transform.localScale = dmgs[1].transform.localScale = Vector3.one; OffTime = destroyTime; Register(null); } Redraw(TextColor, st); isUpdate = false; } /// 합산 윈도우 안의 추가 타격 — 숫자를 더하고, 크리가 섞이면 크리 규격으로 승격한다. void Accumulate(double Damage, eStat Critical, WL.UI.WLCombatTextSettings st) { m_total += Damage; m_hits++; bool crit = Critical == eStat.CRI || Critical == eStat.M_CRI; if (crit && !m_crit) { m_crit = usedmg2 = true; var pos = dmgs[m_Index].transform.localPosition; m_Index = 1; dmgs[0].enabled = false; dmgs[1].enabled = true; dmgs[1].transform.localPosition = pos; m_scale = st.damageCritScale; m_lifeMax = st.damageCritSeconds; m_rise = st.damageRiseSpeed * st.damageCritRiseMultiplier; } m_life = m_lifeMax; // 창이 이어지는 동안 표시 시간을 되감는다 OffTime = m_lifeMax; dmgs[m_Index].alpha = 1f; dmgs[m_Index].transform.localScale = Vector3.one * m_scale; m_mergeUntil = Time.time + Mathf.Max(0f, MergeWindow); Redraw(m_color, st); } void Redraw(string TextColor, WL.UI.WLCombatTextSettings st) { string num = NumberFormatter.FormatNumber(m_total); if (m_wl && st != null && m_hits >= Mathf.Max(2, st.damageMergeHitsMin) && !string.IsNullOrEmpty(st.damageMergeHitsSuffix)) num = num + DSUtil.Format(st.damageMergeHitsSuffix, m_hits); dmgs[0].text = dmgs[1].text = DSUtil.Format("{0}{1}", TextColor, num); } // ── 동시 표시 상한 (기준서 §B 요소 2 「초당 3~5개 겹침」 방지) void Register(WL.UI.WLCombatTextSettings st) { if (!s_active.Contains(this)) s_active.Add(this); if (st == null || st.damageMaxConcurrent <= 0) return; while (s_active.Count > st.damageMaxConcurrent) { int victim = -1; if (st.damageEvictNormalFirst) for (int i = 0; i < s_active.Count; i++) if (s_active[i] != null && s_active[i] != this && !s_active[i].m_crit) { victim = i; break; } if (victim < 0) for (int i = 0; i < s_active.Count; i++) if (s_active[i] != null && s_active[i] != this) { victim = i; break; } if (victim < 0) break; var h = s_active[victim]; EvictedCount++; h.Off(true); // OnDisable 이 목록에서 뺀다 if (s_active.Count > victim && s_active[victim] == h) s_active.RemoveAt(victim); } } void OnDisable() { s_active.Remove(this); m_MergeTarget = null; m_mergeUntil = 0f; if (dmgs != null) { if (dmgs.Length > 0 && dmgs[0] != null) dmgs[0].transform.localScale = Vector3.one; if (dmgs.Length > 1 && dmgs[1] != null) dmgs[1].transform.localScale = Vector3.one; } } /// 프로브(에디트 모드)용 상태 덤프 — 실측 값만. public string DumpState() { var t = dmgs[m_Index].transform; return "wl=" + m_wl + " crit=" + m_crit + " hits=" + m_hits + " total=" + m_total.ToString("N0") + " life=" + m_life.ToString("F2") + "/" + m_lifeMax.ToString("F2") + " scale=" + t.localScale.x.ToString("F3") + " rise=" + m_rise.ToString("F2") + " fadeStart=" + m_fadeStart.ToString("F2") + " alpha=" + dmgs[m_Index].alpha.ToString("F2") + " idx=" + m_Index + " enabled0/1=" + dmgs[0].enabled + "/" + dmgs[1].enabled + " localPos=" + t.localPosition + " text=\"" + dmgs[m_Index].text + "\""; } /// 프로브용 — 지금 설정이 만들어 내는 리듬 값(코드 상수 0 확인용). public static string SettingsDump() { var st = St; if (st == null) return "WLCombatTextSettings 없음/off — 원본 상수(0.9 s · 배율 1 · 상한 없음)로 동작"; return "rhythm=" + st.damageRhythmEnabled + " normal=" + st.damageNormalSeconds + "s crit=" + st.damageCritSeconds + "s critScale=" + st.damageCritScale + " normalScale=" + st.damageNormalScale + " maxConcurrent=" + st.damageMaxConcurrent + " evictNormalFirst=" + st.damageEvictNormalFirst + " mergeWindow=" + MergeWindow.ToString("F2") + "s(811e) useMerge=" + st.damageUseMergeWindow + " scatterPx=" + st.damageScatterPx + " critScatterMul=" + st.damageCritScatterMultiplier; } /// 프로브용 — 활성 목록을 비운다(에디트 모드 반복 실행 대비). public static void ClearActiveList() { s_active.Clear(); EvictedCount = MergedCount = 0; } private void LateUpdate() { var t = dmgs[m_Index].transform; if (!m_wl) { // ── 원본 경로 그대로 t.Translate(new Vector3(0, moveSpeed * Time.deltaTime, 0)); // 텍스트 위치 dmgs[m_Index].alpha = Mathf.Lerp(dmgs[m_Index].alpha, 0, Time.deltaTime * alphaSpeed); // 텍스트 알파값 return; } m_life -= Time.deltaTime; t.Translate(new Vector3(0, m_rise * Time.deltaTime, 0)); float passed = m_lifeMax > 0f ? 1f - Mathf.Clamp01(m_life / m_lifeMax) : 1f; if (passed >= m_fadeStart) { float k = m_fadeStart >= 1f ? 1f : (passed - m_fadeStart) / (1f - m_fadeStart); dmgs[m_Index].alpha = 1f - Mathf.Clamp01(k); } } }