using UnityEngine; /// /// 플레이어 이동 방향에 따른 카메라 리드(전진/후진)·피치 가산(후진) 계산기. /// /// ■ 무엇을 하나 /// 전진(카메라에서 멀어지는 성분) → 궤도 중심과 주시점을 진행 방향으로 leadDistance 만큼 함께 민다. /// 둘을 같은 값으로 밀기 때문에 카메라는 회전 없이 통째로 평행 이동하고, /// 캐릭터는 화면에서 아래로 내려가 진행 방향이 더 보인다. /// 후진(카메라 쪽으로 오는 성분) → ⓐ 궤도 파라미터 cameraHeight 를 올려 더 위에서 내려다보게 하고 /// ⓑ 주시점을 camFwd 축으로 backwardLeadDistance(음수) 만큼 밀어 /// 캐릭터를 화면 위로 올린다(구 WL 앵커 전환과 같은 방향 · PM 결정 ①). /// 정지·측면 → 전부 0 으로 복귀(SmoothDamp). /// /// ■ 어디에 붙나 — MonoBehaviour 가 아니다. /// RealCamera 가 private 필드로 1개 들고 Update_Cam() 안에서 Evaluate() 를 부른다. /// · Update_Cam() 은 CamStatus == 1(평소)에서만 호출된다 → 인트로(0)·스킬 팔로우(2)·탑뷰(3)· /// 보스 연출은 **호출 자체가 안 되므로** 별도의 상태 검사 없이 자동 배제된다. /// · 프리팹(Main Camera.prefab) 을 건드리지 않는다 — 컴포넌트 추가·직렬화 필드 추가가 없다. /// · 값은 전부 Resources/WL/CameraLeadSettings.asset(ScriptableObject · C45)에서 읽는다. /// 에셋이 없거나 enabled = false 면 모든 출력이 0 → RealCamera 는 100% 기존 동작. /// /// ■ 왜 "RealCamera 가 쓴 transform.position 에 오프셋을 더하는 후처리 컴포넌트" 가 아닌가 (기각 근거) /// RealCamera 는 transform.position = Vector3.Lerp(transform.position, camPosition, dt*smooth) 로 /// **자기 출력을 다시 입력으로 읽는** 저역통과 필터다. 여기에 매 프레임 오프셋 e 를 사후 가산하면 /// 평형점이 p = camPosition + ((2a−1)/a)·e (a = dt*smooth) 가 된다. /// 실측 a = (1/60)×4 = 0.0667 → 계수 −13.0. 즉 의도한 오프셋의 13배가 **반대 방향**으로 나오고 /// 프레임률에 따라 계수가 또 변한다. 사후 가산 방식은 성립하지 않는다. /// /// ■ 이동 방향 = 입력이 아니라 위치 차분 /// 조이스틱(PCActor.Run_byJoystick → NavMeshAgent.Move)과 자동 이동(NavMeshAgent 경로)을 모두 포함한다. /// Update_Cam 은 LateUpdate 에서 호출되므로 그 프레임의 이동이 끝난 뒤의 위치다. /// // [System.Serializable] 을 일부러 붙이지 않는다 — 붙이면 RealCamera 의 필드가 직렬화되어 // Main Camera.prefab 이 더러워진다. 값의 SOT 는 ScriptableObject 한 곳이다. public class CameraMoveLead { const string SettingsPath = "WL/CameraLeadSettings"; // Assets/WL/Settings/Resources/WL/CameraLeadSettings.asset static CameraLeadSettings s_Settings; static bool s_Loaded; /// 설정 에셋. 없으면 null — 그 경우 이 기능은 통째로 비활성이다. public static CameraLeadSettings 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; } /// /// WL #811d — 런타임 리드 배율(전진·후진·측면 리드 전부에 곱한다 · 기본 1). 3타 ComboStage(isLast) 에 /// CombatCameraDirector 가 잠시 1.3 으로 올렸다가 되돌린다(값은 WLCombatCameraSettings). 1 이면 기존 동작과 동일. /// public static float LeadMultiplier = 1f; // ── 상태 Transform _bound; // 현재 추적 중인 타겟(바뀌면 리셋) Vector3 _prevPos; float _bindDistance; // 바인드 시점의 DistanceAway — 리드 거리비의 기준 bool _hasPrev; Vector3 _lead, _leadVel; Vector3 _leadDir = Vector3.forward, _leadDirVel; float _pitch, _pitchVel; // ── 진단 (settings.recordSample 이 켜져 있을 때만 갱신) public struct Sample { public float time, speed, fwd01, back01, speed01, leadLen, pitchAdd, yawAdd, camYaw, camPitch; public Vector3 leadDir, camPos, lookPoint; } public Sample LastSample; public Vector3 LeadOffset { get { return _lead; } } public float PitchAdd { get { return _pitch; } } /// 리드·피치를 즉시 0 으로 되돌린다(순간이동·타겟 교체·비활성). public void Snap() { _lead = Vector3.zero; _leadVel = Vector3.zero; _leadDirVel = Vector3.zero; _pitch = 0f; _pitchVel = 0f; } /// /// RealCamera.Update_Cam() 에서 매 프레임 1회 호출한다. /// /// 추적 대상(플레이어) Transform — RealCamera.target /// 추적 대상 Actor — RealCamera 의 m_Target. 기준 속도(Get_MoveSpeed)를 읽는다. null 허용 /// 카메라 Transform — 수평 전방으로 전/후진을 판정한다 /// RealCamera.DistanceAway — 리드 거리비 스케일용 /// RealCamera.cameraHeight — 피치 상한·요 상쇄 계산의 기준 /// 궤도 중심과 주시점에 **함께** 더할 수평 오프셋(월드) /// cameraHeight 에 더할 각(도) /// rotateAround 에 더할 각(도) — 피치 가산의 수평 방위 커플링 상쇄분 public void Evaluate(Transform target, Actor actor, Transform camTf, float distanceAway, float cameraHeight, out Vector3 lead, out float pitchAdd, out float yawAdd) { lead = Vector3.zero; pitchAdd = 0f; yawAdd = 0f; var st = Settings; if (st == null || !st.enabled || target == null || camTf == null) { if (_lead != Vector3.zero || _pitch != 0f) Snap(); _hasPrev = false; return; } float dt = Time.deltaTime; // 타겟 교체(맵 이동·부활·PC 교체) → 차분을 버리고 리셋 if (!ReferenceEquals(_bound, target)) { _bound = target; _prevPos = target.position; _bindDistance = distanceAway; _hasPrev = true; Snap(); return; } if (dt <= 0f) { lead = _lead; pitchAdd = _pitch; yawAdd = YawComp(st, cameraHeight, _pitch); return; } Vector3 delta = target.position - _prevPos; _prevPos = target.position; if (!_hasPrev) { _hasPrev = true; return; } delta.y = 0f; float dist = delta.magnitude; // 순간이동(포탈·워프·리스폰) if (dist > st.teleportDistance) { Snap(); return; } float speed = dist / dt; // 카메라 수평 전방 — +방향으로 이동 = 카메라에서 멀어짐 = 전진 Vector3 camFwd = camTf.forward; camFwd.y = 0f; if (camFwd.sqrMagnitude < 1e-6f) { lead = _lead; pitchAdd = _pitch; yawAdd = YawComp(st, cameraHeight, _pitch); return; } camFwd.Normalize(); // 기준 속도 — 0 이면 캐릭터의 실제 이동속도(테이블·버프 반영)를 쓴다 (C45) float refSpeed = st.speedReference; if (refSpeed <= 0.0001f && actor != null) { // 2026-09-15 PD 「에러 나는 부분 수정」 — 섬(비전투 맵)에서는 PC 스탯 사전에 MOVSPD_Up 키가 없어 // Get_MoveSpeed 가 매 프레임 KeyNotFoundException 을 던졌다 → 실패하면 기준 속도 0(= 아래 임계값 분기)으로 처리. try { refSpeed = actor.Get_MoveSpeed(); } catch (System.Exception) { refSpeed = 0f; } } float lo = st.moveSpeedThreshold; float speed01 = (refSpeed > lo + 0.0001f) ? Mathf.Clamp01((speed - lo) / (refSpeed - lo)) : (speed > lo ? 1f : 0f); float fwd01 = 0f, back01 = 0f; float side01 = 0f, sideSign = 0f; // #759 좌/우 성분 (camRight 축) Vector3 camRight = Vector3.Cross(Vector3.up, camFwd); camRight.Normalize(); if (speed > lo && dist > 1e-6f) { Vector3 dir = delta / dist; float dotR = Vector3.Dot(dir, camRight); side01 = Mathf.Clamp01(Mathf.Abs(dotR)); sideSign = dotR >= 0f ? 1f : -1f; // 측면 무반응 반각: |각| 이 (90° − sideDeadAngle) 를 넘으면 0 float dot = Vector3.Dot(dir, camFwd); float gate = Mathf.Cos((90f - Mathf.Clamp(st.sideDeadAngle, 0f, 45f)) * Mathf.Deg2Rad); float denom = Mathf.Max(1f - gate, 1e-4f); fwd01 = Mathf.Clamp01((dot - gate) / denom); back01 = Mathf.Clamp01((-dot - gate) / denom); // 리드가 향하는 방향 자체도 감쇠 — 급반전 때 오프셋이 화면을 가로지르지 않게 (구 WL #720) if (st.leadDirSmoothTime > 0.001f) _leadDir = Vector3.SmoothDamp(_leadDir, dir, ref _leadDirVel, st.leadDirSmoothTime, Mathf.Infinity, dt); else _leadDir = dir; if (_leadDir.sqrMagnitude > 1e-6f) _leadDir.Normalize(); else _leadDir = dir; } // ── 리드 거리비 스케일 (실내·보스에서 카메라가 가까우면 리드도 함께 줄어든다) float leadScale = LeadMultiplier; // #811d 3타 리드 배율(기본 1) if (st.scaleLeadByDistance) { float refDist = st.leadReferenceDistance > 0.0001f ? st.leadReferenceDistance : _bindDistance; if (refDist > 0.0001f) leadScale *= distanceAway / refDist; } // ── 전진 리드(진행 방향) + 후진 리드(camFwd 축 · backwardLeadDistance 음수면 카메라 쪽으로 당김) Vector3 wantedLead = _leadDir * (st.leadDistance * leadScale * fwd01 * speed01) + camFwd * (st.backwardLeadDistance * leadScale * back01 * speed01) + camRight * (st.lateralLeadDistance * leadScale * side01 * sideSign * speed01); // #759 측면 리드 // 시선 방향 성분 제거(선택) — 수평 리드는 34.93° 카메라에서 82 % 가 줌인으로 소모된다. // blend = 1 이면 시선에 수직인 성분만 남아 깊이 변화 0(구 WL screenAnchorY/FocusShift 와 같은 방향). if (st.leadPerpendicularBlend > 0.0001f && wantedLead.sqrMagnitude > 1e-8f) wantedLead -= Vector3.Project(wantedLead, camTf.forward) * Mathf.Clamp01(st.leadPerpendicularBlend); if (st.leadSmoothTime > 0.001f) _lead = Vector3.SmoothDamp(_lead, wantedLead, ref _leadVel, st.leadSmoothTime, Mathf.Infinity, dt); else { _lead = wantedLead; _leadVel = Vector3.zero; } // ── 후진 피치 (총합이 pitchTotalMax 를 넘지 않게 입력 단계에서 한 번만 제한) float wantedPitch = Mathf.Max(0f, Mathf.Min(st.backwardPitchAdd * back01 * speed01, st.pitchTotalMax - cameraHeight)); float maxSpd = st.pitchMaxSpeed > 0.0001f ? st.pitchMaxSpeed : Mathf.Infinity; if (st.pitchSmoothTime > 0.001f) _pitch = Mathf.SmoothDamp(_pitch, wantedPitch, ref _pitchVel, st.pitchSmoothTime, maxSpd, dt); else { _pitch = wantedPitch; _pitchVel = 0f; } lead = _lead; pitchAdd = _pitch; yawAdd = YawComp(st, cameraHeight, _pitch); if (st.recordSample) { LastSample.time = Time.time; LastSample.speed = speed; LastSample.fwd01 = fwd01; LastSample.back01 = back01; LastSample.speed01 = speed01; LastSample.leadLen = _lead.magnitude; LastSample.leadDir = _leadDir; LastSample.pitchAdd = _pitch; LastSample.yawAdd = yawAdd; LastSample.camPos = camTf.position; LastSample.camYaw = camTf.eulerAngles.y; LastSample.camPitch = camTf.eulerAngles.x; LastSample.lookPoint = target.position + _lead; } } /// /// 피치 가산이 만드는 수평 방위 회전을 정확히 상쇄한다. /// /// RealCamera 의 궤도 벡터는 Quaternion.Euler(h, r, 0) * Vector3.one 이다. /// 이 벡터의 수평 방위각은 r + atan2(1, sin h + cos h) 이므로, h 를 올리면 r 이 그대로여도 /// 카메라가 옆으로 돈다(실측: h = 78° 에서 파라미터 +1° 당 +0.32°, +6° 면 +2.2° · 반지름 4.65 m 에서 약 18 cm). /// 따라서 r 에 그 차이만큼을 빼 주면 방위가 고정된다. /// static float YawComp(CameraLeadSettings st, float baseHeight, float add) { if (st == null || !st.compensatePitchYawCoupling || Mathf.Abs(add) < 1e-4f) return 0f; return -(YawOffsetDeg(baseHeight + add) - YawOffsetDeg(baseHeight)); } static float YawOffsetDeg(float h) { float r = h * Mathf.Deg2Rad; return Mathf.Atan2(1f, Mathf.Sin(r) + Mathf.Cos(r)) * Mathf.Rad2Deg; } /// 진단용 — 파라미터 cameraHeight 에 대응하는 실제 궤도 고도각(도). public static float RealElevationDeg(float h) { float r = h * Mathf.Deg2Rad; return Mathf.Asin(Mathf.Clamp((Mathf.Sin(r) - Mathf.Cos(r)) / Mathf.Sqrt(3f), -1f, 1f)) * Mathf.Rad2Deg; } }