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

288 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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<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();
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);
}
/// <summary>피격 번쩍임 1회(테두리 + 가장자리). 프로브가 직접 부르기도 한다.</summary>
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); }
/// <summary>알파 감쇠 진행. 프로브가 시간을 넣어 같은 경로로 검증한다.</summary>
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);
}
// ── 구성 · 배치 ───────────────────────────────────────────────────────
/// <summary>자기 노드 아래 가장자리 비네트만 만든다(HP 바 테두리는 EnsureBorder 가 런타임에).</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
_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<RectTransform>();
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");
}
/// <summary>
/// HP 바를 찾아 그 둘레에 붉은 테두리 4띠를 만든다(런타임 전용 · 프리팹에 굽지 않는다).
/// Safe Area 재부모화 뒤에도 옛 경로 문자열이 그대로 먹도록 WLVignetteUtil.FindUiPath 를 쓴다.
/// </summary>
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)";
}
/// <summary>런타임에 만든 테두리 노드를 지운다(C8 롤백 · 프로브 정리용).</summary>
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<Canvas>(true);
return WLVignetteUtil.UnitsPerPx(_canvas);
}
// ── 검증 전용 ────────────────────────────────────────────────────────
/// <summary>가짜 PC 피격을 **실제 CombatEvents 경로로** 발행한다(Play 0 검증용).</summary>
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();
}
}
}