using System.Collections; using UnityEngine; namespace WL.Feel { /// /// 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 개는 재사용하지 않고 짧게 끝난다). /// public static class WLHitFeel { const string SettingsPath = "WL/WLHitFeelSettings"; static WLHitFeelSettings s_Settings; static bool s_Loaded; /// 설정 에셋. 없으면 null — 그 경우 이 기능은 통째로 비활성이다. public static WLHitFeelSettings Settings { get { if (!s_Loaded) { s_Loaded = true; s_Settings = Resources.Load(SettingsPath); } return s_Settings; } } /// 에디터/진단에서 에셋을 갈아끼울 때. public static void ReloadSettings() { s_Loaded = false; s_Settings = null; } /// /// 진단·A/B 전용 런타임 스위치. **에셋을 건드리지 않고** 이 기능만 끈다. /// (Play 중에 설정 SO 인스턴스를 고치면 디스크로 새는 사고가 있었다 — 그래서 별도 필드로 둔다.) /// Play 종료 시 도메인 리로드로 자동 false 로 돌아간다. /// public static bool RuntimeDisabled; // ── 상태 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; } /// /// 셰이커는 **RealCamera 가 직접 transform.position 을 쓰는 그 게임오브젝트**에 붙여야 한다. /// (카메라가 자식이면 부모가 움직일 때 우리 오프셋을 되돌리지 못해 누적된다) /// RealCamera 가 없을 때만 Camera.main 으로 폴백한다. /// 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(); if (s_Shaker == null && create) s_Shaker = host.AddComponent(); 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(); } return s_Runner; } } /// /// 타격 훅. /// /// /// true = WL 이 카메라 연출을 담당했다(또는 의도적으로 억제했다) → 호출측의 기존 셰이크를 부르지 말 것. /// false = 이 기능이 비활성 → 호출측이 기존 동작을 그대로 수행할 것. /// 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 } } }