// ───────────────────────────────────────────────────────────────────────────── // HitVignette.cs — 피격 연출: HP 바 붉은 테두리 0.3 s + 화면 가장자리 비네트 (WL-813tj · 813j · #813) // // 기준서 v1 §D-1 813j 행: "HP 바 붉은 테두리(피격 0.3 s · UI 이미지 · 포스트 금지)" // §B 요소 8 위험: 무적 해제 뒤 "맞고 있다"가 화면에 보여야 회복·회피·부활이 의미를 갖는다. // // ■ 이벤트 (구독만) // WL.Combat.Core.CombatEvents.Damaged — 실측 발생 지점 `Assets/Script/Character/MyActor.cs:309` // = **PC 전용**(MyActor)이라 몹 피격이 섞이지 않는다. 페이로드의 invincible 이 무적 분기를 알려준다. // 🔴 Q1 QA 실측: playerInvincible=1 인 동안 PC 피격 2045회가 전부 dmg=0 으로 들어온다 // → 기본값 ignoreInvincibleHits=true 로 무적 피격을 버린다(813i 가 무적을 풀면 저절로 살아난다). // // ■ 붉은 테두리의 대상 // 실측 PC HP 바 = `Common/MyInfoUI/Slider_hp` (295×12 px · MyInfoUI 는 좌상단 (117.9, −44.9)). // 813tj 가 Common 을 `WL_SafeArea_Back` 아래로 옮겼으므로 경로 탐색은 Safe Area 패널을 건너뛴다 // (WLVignetteUtil.FindUiPath). 테두리 노드는 **런타임에** 바 자식으로 만든다 — // NewGameUI.prefab 에 굽지 않는다(ⓓ diff 최소 · 813c/813g 규칙). // // ■ 값 = WLSurvivalUiSettings.asset (C45) · 시간 = Time.unscaledTime(히트스톱 무관) // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using System.Text; using UnityEngine; using UnityEngine.UI; using WL.Combat.Core; namespace WL.UI { [DisallowMultipleComponent] [RequireComponent(typeof(RectTransform))] public class HitVignette : MonoBehaviour { public const string BorderNodeName = "WL_HitBorder"; [SerializeField] private RectTransform vignetteRoot; private RectTransform _rt; private Canvas _canvas; private WLEdgeVignette _edge; private WLEdgeVignette _border; // HP 바 둘레 4띠(단색) private RectTransform _borderRoot; private bool _subscribed; private float _edgeStart = -1f, _borderStart = -1f, _lastHit = -999f; // ── 진단 ────────────────────────────────────────────────────────────── public int DamagedSeen { get; private set; } public int InvincibleSkipped { get; private set; } public int ThrottleSkipped { get; private set; } public int FlashCount { get; private set; } public string BorderTargetPath { get; private set; } = ""; public bool BorderBound { get { return _border != null && _border.IsBuilt; } } public float EdgeAlpha { get { return _edge != null ? _edge.CurrentAlpha : 0f; } } public float BorderAlpha { get { return _border != null ? _border.CurrentAlpha : 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(); string border = EnsureBorder(); HideNow(); var s = WLSurvivalUiSettings.Instance; Subscribe(s == null || s.hitFeedbackEnabled); return "initialized subscribed=" + _subscribed + " · " + layout + " · " + border; } public void Subscribe(bool on) { if (on == _subscribed) return; _subscribed = on; if (on) CombatEvents.Damaged.Add(OnDamaged); else CombatEvents.Damaged.Remove(OnDamaged); } // ── 구독 ────────────────────────────────────────────────────────────── private void OnDamaged(in DamagedEvent e) { DamagedSeen++; var s = WLSurvivalUiSettings.Instance; if (s == null || !s.hitFeedbackEnabled) return; if (e.invincible && s.ignoreInvincibleHits) { InvincibleSkipped++; return; } float now = Time.unscaledTime; if (now - _lastHit < s.hitMinIntervalSeconds) { ThrottleSkipped++; return; } Flash(now); } /// 피격 번쩍임 1회(테두리 + 가장자리). 프로브가 직접 부르기도 한다. public string Flash(float now) { var s = WLSurvivalUiSettings.Instance; if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 표시 안 함"; _lastHit = now; FlashCount++; _borderStart = s.hitBorderEnabled ? now : -1f; _edgeStart = s.hitVignetteEnabled ? now : -1f; if (_borderStart >= 0f && !BorderBound) EnsureBorder(); Tick(now); if (s.verboseLog) Debug.Log("[HitVignette] flash border=" + s.hitBorderSeconds + "s edge=" + s.hitVignetteSeconds + "s"); return "flash border=" + (_borderStart >= 0f ? s.hitBorderSeconds.ToString("F2") + "s" : "off") + " edge=" + (_edgeStart >= 0f ? s.hitVignetteSeconds.ToString("F2") + "s" : "off") + " bound=" + BorderBound; } private void Update() { Tick(Time.unscaledTime); } /// 알파 감쇠 진행. 프로브가 시간을 넣어 같은 경로로 검증한다. public void Tick(float now) { var s = WLSurvivalUiSettings.Instance; if (s == null) return; if (_borderStart >= 0f && _border != null && _border.IsBuilt) { float t = now - _borderStart; if (t >= s.hitBorderSeconds) { _borderStart = -1f; _border.SetAlpha(s.hitBorderColor, 0f); } else _border.SetAlpha(s.hitBorderColor, 1f - Mathf.Clamp01(t / s.hitBorderSeconds)); } if (_edgeStart >= 0f && _edge != null && _edge.IsBuilt) { float t = now - _edgeStart; if (t >= s.hitVignetteSeconds) { _edgeStart = -1f; _edge.SetAlpha(s.hitVignetteColor, 0f); } else _edge.SetAlpha(s.hitVignetteColor, 1f - Mathf.Clamp01(t / s.hitVignetteSeconds)); } } public void HideNow() { _edgeStart = -1f; _borderStart = -1f; var s = WLSurvivalUiSettings.Instance; if (_edge != null && _edge.IsBuilt) _edge.SetAlpha(s != null ? s.hitVignetteColor : Color.red, 0f); if (_border != null && _border.IsBuilt) _border.SetAlpha(s != null ? s.hitBorderColor : Color.red, 0f); } // ── 구성 · 배치 ─────────────────────────────────────────────────────── /// 자기 노드 아래 가장자리 비네트만 만든다(HP 바 테두리는 EnsureBorder 가 런타임에). public bool BuildIfNeeded() { if (_rt == null) _rt = GetComponent(); _edge = WLVignetteUtil.Build(_rt, "Edge"); bool made = vignetteRoot == null; if (vignetteRoot == null) vignetteRoot = _edge.root; return made; } public string ApplyLayout() { if (_rt == null) _rt = GetComponent(); var s = WLSurvivalUiSettings.Instance; if (s == null) return "WLSurvivalUiSettings 에셋 없음 — 배치 건너뜀"; WLVignetteUtil.Stretch(_rt); _rt.localScale = Vector3.one; float u = UnitsPerPx(); if (_edge == null || !_edge.IsBuilt) _edge = WLVignetteUtil.Build(_rt, "Edge"); WLVignetteUtil.ApplyLayout(_edge, s.hitVignetteThicknessPx, u, s.hitVignetteColor); if (vignetteRoot == null) vignetteRoot = _edge.root; return "layout edgeThickness=" + (s.hitVignetteThicknessPx * u).ToString("F1") + " unitsPerPx=" + u.ToString("F4"); } /// /// HP 바를 찾아 그 둘레에 붉은 테두리 4띠를 만든다(런타임 전용 · 프리팹에 굽지 않는다). /// Safe Area 재부모화 뒤에도 옛 경로 문자열이 그대로 먹도록 WLVignetteUtil.FindUiPath 를 쓴다. /// public string EnsureBorder() { var s = WLSurvivalUiSettings.Instance; if (s == null || !s.hitBorderEnabled) return "테두리 off"; var canvasRoot = WLVignetteUtil.FindCanvasRoot(transform); var target = WLVignetteUtil.FindUiPath(canvasRoot, s.hitBorderTargetPath) as Transform; var targetRt = target as RectTransform; if (targetRt == null) { BorderTargetPath = "(못 찾음) " + s.hitBorderTargetPath; return "🔴 HP 바 경로 없음: " + s.hitBorderTargetPath + " — 테두리 생략(가장자리 비네트는 그대로)"; } BorderTargetPath = GetPath(canvasRoot, targetRt); float u = UnitsPerPx(); float t = Mathf.Max(0f, s.hitBorderThicknessPx) * u; _border = WLVignetteUtil.Build(targetRt, BorderNodeName); _borderRoot = _border.root; if (_borderRoot != null) { // 바 바깥으로 두께만큼 벌린 사각형 → 그 안쪽 가장자리 4띠가 곧 테두리가 된다 _borderRoot.anchorMin = Vector2.zero; _borderRoot.anchorMax = Vector2.one; _borderRoot.pivot = new Vector2(0.5f, 0.5f); _borderRoot.offsetMin = new Vector2(-t, -t); _borderRoot.offsetMax = new Vector2(t, t); _borderRoot.localScale = Vector3.one; } WLVignetteUtil.ApplyLayout(_border, s.hitBorderThicknessPx, u, s.hitBorderColor); // 테두리는 그라디언트가 아니라 단색이어야 "테두리" 로 읽힌다 for (int i = 0; i < _border.strips.Length; i++) if (_border.strips[i] != null) _border.strips[i].sprite = null; _border.SetAlpha(s.hitBorderColor, 0f); return "테두리 부착 " + BorderTargetPath + " thickness=" + t.ToString("F1") + "u(" + s.hitBorderThicknessPx + "px)"; } /// 런타임에 만든 테두리 노드를 지운다(C8 롤백 · 프로브 정리용). public string RemoveBorder() { if (_borderRoot == null) { var canvasRoot = WLVignetteUtil.FindCanvasRoot(transform); var s = WLSurvivalUiSettings.Instance; var target = s != null ? WLVignetteUtil.FindUiPath(canvasRoot, s.hitBorderTargetPath) : null; if (target != null) _borderRoot = target.Find(BorderNodeName) as RectTransform; } if (_borderRoot == null) return "테두리 없음"; var go = _borderRoot.gameObject; _borderRoot = null; _border = null; if (Application.isPlaying) Destroy(go); else DestroyImmediate(go); return "테두리 제거"; } private static string GetPath(Transform root, Transform t) { var sb = new StringBuilder(t.name); var cur = t.parent; while (cur != null && cur != root) { sb.Insert(0, cur.name + "/"); cur = cur.parent; } return sb.ToString(); } public float UnitsPerPx() { if (_canvas == null) _canvas = GetComponentInParent(true); return WLVignetteUtil.UnitsPerPx(_canvas); } // ── 검증 전용 ──────────────────────────────────────────────────────── /// 가짜 PC 피격을 **실제 CombatEvents 경로로** 발행한다(Play 0 검증용). public static string RaiseFakeDamaged(double damage, bool invincible) { var e = new DamagedEvent { victim = null, attacker = null, dinfo = null, damage = damage, invincible = invincible, time = Time.unscaledTime, frame = Time.frameCount }; CombatEvents.Damaged.Dispatch(in e); return "dispatched Damaged(dmg=" + damage + " invincible=" + invincible + ") subscribers=" + CombatEvents.Damaged.Count; } public string Dump() { var sb = new StringBuilder(); var s = WLSurvivalUiSettings.Instance; sb.AppendLine("[HitVignette] subscribed=" + _subscribed + " flashes=" + FlashCount + " seen=" + DamagedSeen + " 무적스킵=" + InvincibleSkipped + " 스로틀스킵=" + ThrottleSkipped + " edgeAlpha=" + EdgeAlpha.ToString("F3") + " borderAlpha=" + BorderAlpha.ToString("F3")); sb.AppendLine(" 구독자 Damaged=" + CombatEvents.Damaged.Count + " Enabled=" + CombatEvents.Enabled); sb.AppendLine(" 테두리 대상=" + (string.IsNullOrEmpty(BorderTargetPath) ? "(미탐색)" : BorderTargetPath) + " bound=" + BorderBound); if (_borderRoot != null) sb.AppendLine(" 테두리 root offMin" + _borderRoot.offsetMin + " offMax" + _borderRoot.offsetMax + " rect=" + _borderRoot.rect.width.ToString("F1") + "x" + _borderRoot.rect.height.ToString("F1")); if (_edge != null && _edge.IsBuilt) for (int i = 0; i < _edge.strips.Length; i++) { var img = _edge.strips[i]; if (img == null) continue; sb.AppendLine(" edge[" + img.name + "] size" + img.rectTransform.sizeDelta + " scale" + img.rectTransform.localScale + " enabled=" + img.enabled + " a=" + img.color.a.ToString("F3") + " raycast=" + img.raycastTarget); } if (_border != null && _border.IsBuilt) for (int i = 0; i < _border.strips.Length; i++) { var img = _border.strips[i]; if (img == null) continue; sb.AppendLine(" border[" + img.name + "] size" + img.rectTransform.sizeDelta + " enabled=" + img.enabled + " a=" + img.color.a.ToString("F3") + " sprite=" + (img.sprite != null ? img.sprite.name : "(단색)")); } sb.AppendLine(" 설정 " + (s == null ? "(에셋 없음)" : "border=" + s.hitBorderSeconds + "s/" + s.hitBorderThicknessPx + "px edge=" + s.hitVignetteSeconds + "s/" + s.hitVignetteThicknessPx + "px 무적무시=" + s.ignoreInvincibleHits + " 스로틀=" + s.hitMinIntervalSeconds + "s")); return sb.ToString(); } } }