288 lines
16 KiB
C#
288 lines
16 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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();
|
||
}
|
||
}
|
||
}
|