Project_WL/Assets/WL/Feel/WLHitFeel.cs

236 lines
10 KiB
C#

using System.Collections;
using UnityEngine;
namespace WL.Feel
{
/// <summary>
/// PD #797 — "타격 시점에만 살짝" 임팩트 연출의 진입점.
///
/// ■ 훅 (힘민지 코어 변경은 이 1 줄뿐)
/// Actor.cs Get_Damage() 안, Cal_Damage(_dinfo) 직후의 기존 카메라 셰이크 줄을
/// if (_dinfo.Beater.IsMainPC() && !WL.Feel.WLHitFeel.OnHit(this, _dinfo))
/// MyValue.m_RealCamera.ShakeCamera(1);
/// 로 바꾼다. OnHit 이 false 를 반환하면(에셋 없음·enabled=false) 기존 동작이 100% 그대로다.
///
/// ■ 왜 Cal_Damage 직후인가
/// · 그 시점에 _dinfo.Damage 가 방어력·감쇠까지 끝난 최종값이다.
/// · Cal_Damage 가 _dinfo.IsDeadByThisDamage 를 세팅한 뒤라 킬 여부를 알 수 있다.
/// · Cal_Damage 자체는 도트(화상·중독)·연쇄 데미지에서도 불리므로(Actor.cs 1114/1400/1451/1534)
/// 그 안에 훅을 넣으면 '타격 시점' 이 아닌 도트 틱마다 연출이 터진다.
/// Get_Damage 경로는 직접 타격 전용이라 여기가 맞다.
///
/// ■ 스로틀 — 스윙 1회당 1발
/// 근접 스윙 1 회가 0.02 s 박스 투사체로 군집의 몹 5~7 마리를 **같은 프레임에** 때린다(PM 실측).
/// throttleSeconds(기본 0.08 s) 안의 후속 타격은 연출을 건너뛴다.
/// 콤보 간격이 0.42~0.47 s 이므로 3 히트는 각각 정상 발동한다.
///
/// ■ 모바일 예산
/// 드로우 추가 0 (플래시 옵션을 켰을 때만 +1) · 포스트 프로세싱 볼륨 오버라이드 없음 ·
/// 셰이크는 transform 만 · 매 타격 GC 할당 없음(코루틴 3 개는 재사용하지 않고 짧게 끝난다).
/// </summary>
public static class WLHitFeel
{
const string SettingsPath = "WL/WLHitFeelSettings";
static WLHitFeelSettings s_Settings;
static bool s_Loaded;
/// <summary>설정 에셋. 없으면 null — 그 경우 이 기능은 통째로 비활성이다.</summary>
public static WLHitFeelSettings Settings
{
get
{
if (!s_Loaded)
{
s_Loaded = true;
s_Settings = Resources.Load<WLHitFeelSettings>(SettingsPath);
}
return s_Settings;
}
}
/// <summary>에디터/진단에서 에셋을 갈아끼울 때.</summary>
public static void ReloadSettings() { s_Loaded = false; s_Settings = null; }
/// <summary>
/// 진단·A/B 전용 런타임 스위치. **에셋을 건드리지 않고** 이 기능만 끈다.
/// (Play 중에 설정 SO 인스턴스를 고치면 디스크로 새는 사고가 있었다 — 그래서 별도 필드로 둔다.)
/// Play 종료 시 도메인 리로드로 자동 false 로 돌아간다.
/// </summary>
public static bool RuntimeDisabled;
// ── WL #811b: 코어 이벤트 파이프라인 편입 — HitConfirmed 구독자.
// CombatEvents.RaiseHitConfirmed 가 LastCoreHandled(= OnHit 반환값)로 기존 false 반환 규약(원본 ShakeCamera 폴백)을 그대로 잇는다.
// 코어가 비활성이면 RaiseHitConfirmed 가 OnHit 을 직접 부르므로 이 구독은 돌지 않는다(동작 동일).
public static bool LastCoreHandled;
public static bool CoreSubscribed;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void RegisterCoreSubscriber()
{
WL.Combat.Core.CombatEvents.HitConfirmed.Add(OnHitConfirmedEvent);
CoreSubscribed = true;
}
static void OnHitConfirmedEvent(in WL.Combat.Core.HitConfirmedEvent e) { LastCoreHandled = OnHit(e.victim, e.dinfo); }
// ── 상태
static float s_LastFiredTime = -999f;
static WLHitFeelRunner s_Runner;
static WLHitFeelCameraShake s_Shaker;
static GameObject s_ShakerHost;
// ── 진단 (WL797_Probe 가 읽는다)
public static int HitCount; // 훅이 불린 총 횟수
public static int FiredCount; // 실제로 연출이 나간 횟수
public static int ThrottledCount; // 스로틀로 건너뛴 횟수
public static float LastHitTime; // unscaled
public static float LastFiredTime; // unscaled
public static float LastHitStopMeasured; // 실측된 히트스톱 길이(초)
public static int LastFiredFrame; // 연출이 나간 프레임 번호
public static Transform LastVictimTf; // 마지막으로 펀치를 건 몹
public static float LastVictimBaseScaleX; // 그 몹의 펀치 직전 스케일(비율 계산 기준)
public static bool LastWasKill;
public static void ResetDiagnostics()
{
HitCount = FiredCount = ThrottledCount = 0;
LastHitTime = LastFiredTime = 0f;
LastHitStopMeasured = 0f;
s_LastFiredTime = -999f;
var sh = GetShaker(false);
if (sh != null) sh.MaxOffsetMagnitude = 0f;
}
/// <summary>
/// 셰이커는 **RealCamera 가 직접 transform.position 을 쓰는 그 게임오브젝트**에 붙여야 한다.
/// (카메라가 자식이면 부모가 움직일 때 우리 오프셋을 되돌리지 못해 누적된다)
/// RealCamera 가 없을 때만 Camera.main 으로 폴백한다.
/// </summary>
public static WLHitFeelCameraShake GetShaker(bool create)
{
GameObject host = null;
if (MyValue.m_RealCamera != null) host = MyValue.m_RealCamera.gameObject;
else if (Camera.main != null) host = Camera.main.gameObject;
if (host == null) return null;
if (s_Shaker != null && s_ShakerHost == host) return s_Shaker;
s_ShakerHost = host;
s_Shaker = host.GetComponent<WLHitFeelCameraShake>();
if (s_Shaker == null && create) s_Shaker = host.AddComponent<WLHitFeelCameraShake>();
return s_Shaker;
}
static WLHitFeelRunner Runner
{
get
{
if (s_Runner == null)
{
// hideFlags 를 주지 않는다 — 검증 때 하이어라키에서 보이는 편이 낫고,
// HideAndDontSave 는 DontDestroyOnLoad 와 겹쳐 경고를 낸다.
var go = new GameObject("__WLHitFeel");
Object.DontDestroyOnLoad(go);
s_Runner = go.AddComponent<WLHitFeelRunner>();
}
return s_Runner;
}
}
/// <summary>
/// 타격 훅.
/// </summary>
/// <returns>
/// true = WL 이 카메라 연출을 담당했다(또는 의도적으로 억제했다) → 호출측의 기존 셰이크를 부르지 말 것.
/// false = 이 기능이 비활성 → 호출측이 기존 동작을 그대로 수행할 것.
/// </returns>
public static bool OnHit(Actor victim, DamageInfo dinfo)
{
var st = Settings;
if (st == null || !st.enabled || RuntimeDisabled) return false; // ← 롤백 경로: 기존 동작 100 %
if (victim == null || dinfo == null) return false;
var beater = dinfo.Beater;
if (beater == null || !beater.IsMainPC()) return false; // 내 PC 가 때린 것만
if (victim.IsMainPC()) return false; // PC 피격은 이번 범위 밖
bool suppressLegacy = st.suppressLegacyShake;
if (dinfo.Damage <= 0d) return suppressLegacy; // 데미지 0 = 연출 없음
HitCount++;
LastHitTime = Time.unscaledTime;
// ── 스윙 1회당 1발
if (LastHitTime - s_LastFiredTime < st.throttleSeconds)
{
ThrottledCount++;
if (st.logHits)
Debug.Log(string.Format("[WLHitFeel] THROTTLED t={0:F3} victim={1} dmg={2}",
LastHitTime, victim.name, dinfo.Damage));
return suppressLegacy;
}
s_LastFiredTime = LastHitTime;
LastFiredTime = LastHitTime;
LastFiredFrame = Time.frameCount;
FiredCount++;
bool kill = dinfo.IsDeadByThisDamage;
LastWasKill = kill;
if (st.logHits)
Debug.Log(string.Format("[WLHitFeel] FIRE t={0:F3} frame={1} victim={2} dmg={3} kill={4}",
LastHitTime, Time.frameCount, victim.name, dinfo.Damage, kill));
// ── ① 히트스톱
if (st.hitStopEnabled)
{
float dur = Mathf.Min(kill ? st.hitStopKillDuration : st.hitStopDuration, st.hitStopMax);
if (dur > 0.0001f) Runner.RunHitStop(st, dur);
}
// ── ② 카메라 셰이크
if (st.cameraShakeEnabled)
{
var shaker = GetShaker(true);
if (shaker != null)
{
float amp = st.shakeAmplitude * (kill ? Mathf.Max(1f, st.shakeKillMultiplier) : 1f);
shaker.Shake(amp, st.shakeDuration, st.shakeFrequency, st.shakeAxisWeight);
}
}
// ── ③ 피격 몹 스케일 펀치
if (st.scalePunchEnabled && !(st.skipPunchOnKill && kill) && !(st.skipPunchOnBoss && victim.IsSubRole(eSubRol.Boss)))
{
LastVictimTf = victim.transform;
LastVictimBaseScaleX = LastVictimTf.localScale.x;
Runner.RunScalePunch(st, victim);
}
// ── ④ 화면 플래시 (기본 OFF)
if (st.flashEnabled) Runner.RunFlash(st);
// ── ⑤ 진동 (기본 OFF)
if (st.hapticEnabled) PlayHaptic();
return suppressLegacy;
}
static void PlayHaptic()
{
#if UNITY_ANDROID || UNITY_IOS
try
{
Lofelt.NiceVibrations.HapticPatterns.PlayPreset(
Lofelt.NiceVibrations.HapticPatterns.PresetType.LightImpact);
}
catch { /* 햅틱 미지원 기기 — 무시 */ }
#endif
}
}
}