Project_WL/Assets/WL/Scripts/Player/FollowCamera.cs

813 lines
45 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.

using UnityEngine;
namespace WL.Player
{
/// <summary>
/// 모바일용 추적 카메라. 회전·줌 등 사용자 시야 조절 입력은 없고, 시야 모드 2종(1 숄더뷰 / 2 근거리) 토글만 지원한다.
/// 숄더뷰(ThirdPersonFollow)는 요가 이동 방향을 **연속 감쇠**로 추종하는 근접 3인칭이고,
/// 근거리(ThirdPerson)는 피치·요가 맵 기준으로 고정된 쿼터뷰다.
/// 모든 수치는 FollowCameraSettings(ScriptableObject)에서 읽는다(C45).
///
/// PD #719 재설계 — 모드 1 의 "멈췄다 따라잡기·툭 튐"은 서로 싸우는 게이트가 겹쳐서 생겼다.
/// 없앤 것: 요 각속도 상한 · 요 데드존(0) · 주시점 데드존 · 카메라 속도 상한(캐릭터 속도 배수) · 급반전 유예 · 반전 스냅 상태기
/// 남긴 것: 정지 시 요 유지 하나뿐. 큰 각도는 **시간상수를 짧게** 해서 자연스럽게 빨리 돌린다(상태기 불필요).
/// PD #719 보충 — 모드 1 의 룩어헤드는 모드 2 값 × 거리비로 스케일한다("모드 2 를 가까이 당긴 카메라").
/// PD #721 — 모드 2 에서 캐릭터가 시야에서 사라지지 않게 3중으로 막는다.
/// ① 가림 회피 거리를 **감쇠**한다. 매 프레임 새로 캐스팅해 바로 적용하던 구 동작은 히트/미스가 갈리는 순간
/// 15m ↔ 3m 로 순간이동했다(PD 조작 25초 실측: 거리 최소 3.21m·평균 7.97m·62%가 10m 미만 · 카메라 속도 68m/s).
/// ② 캐릭터 뷰포트 좌표를 안전 사각형 안에 들어오도록 룩어헤드·포커스 오프셋을 줄인다.
/// ③ 프레임당 카메라 이동에 스파이크 상한을 건다(전환 보간 제외).
/// </summary>
[RequireComponent(typeof(Camera))]
public class FollowCamera : MonoBehaviour
{
[Header("설정 (ScriptableObject)")]
[SerializeField] private FollowCameraSettings settings;
[Header("추적 대상")]
[SerializeField] private Transform target;
[Tooltip("target 이 비어 있을 때 이 태그로 찾는다")]
[SerializeField] private string fallbackTargetTag = "Player";
[Tooltip("1인칭에서 숨길 캐릭터 모델 루트. 비어 있으면 target 하위 전체")]
[SerializeField] private Transform characterModelRoot;
[Header("지형 충돌 회피 대상 레이어")]
[SerializeField] private LayerMask obstacleMask = ~0;
[Header("현재 상태 (읽기 전용)")]
[SerializeField] private int modeIndex = 1;
private Vector3 _posVelocity;
private Camera _cam;
private Renderer[] _characterRenderers;
private int _charHiddenState = -1; // -1 = 미적용(첫 호출은 항상 반영) · 0 = 표시 · 1 = 숨김
private float _anchorY = -1f; // 현재 적용 중인 앵커(보간 결과)
private float _anchorVelocity;
private float _followYaw; // 숄더뷰에서 이동 방향을 감쇠 추종한 요
private bool _followYawInit;
private float _followYawVelocity;
private Vector3 _lastTargetPos; // 이동 여부 판정용 직전 프레임 대상 위치
private readonly RaycastHit[] _hitBuffer = new RaycastHit[16]; // 충돌 회피용 — 매 프레임 할당 방지
private float _charSpeed; // 캐릭터 수평 속도(m/s)
private Vector3 _charVelocityDir = Vector3.forward; // 캐릭터 수평 이동 방향(정지 시 마지막 방향 유지)
private Vector3 _lookAhead; // 이동 방향 주시점 오프셋(월드 · 수평)
private Vector3 _lookAheadVel;
private Vector3 _lookAheadDir = Vector3.forward; // 감쇠된 룩어헤드 방향(급전환 시 휘두름 방지)
private Vector3 _lookAheadDirVel;
private Vector3 _livePivot; // 이번 프레임 주시점(오프셋 제외)
private Vector3 _smoothPivot; // PD #722 궤도 중심 — 캐릭터 주시점을 감쇠 추종한 점
private Vector3 _smoothPivotVel;
private bool _smoothPivotInit;
private float _pitch = -1f; // 현재 적용 중인 이동 피치(보간 결과 · -1 = 미초기화)
private float _pitchVel;
private float _occlusionPitch; // PD #721 ①-b 가림 시 위로 올리는 추가 피치(도)
private float _occlusionPitchVel;
private float _avoidDistance = -1f; // PD #721 ①-a 감쇠된 충돌 회피 거리(m · -1 = 미초기화)
private float _avoidDistanceVel;
private float _rawAvoidDistance; // 이번 프레임 캐스팅 결과(감쇠 전) — 진단용
private float _offsetScale = 1f; // PD #721 ② 안전 사각형을 지키려고 줄인 오프셋 배율(1 = 그대로)
private bool _occludedNow;
private float _blendTimer; // 모드 전환 보간 잔여 시간(초)
private Vector3 _blendFromPos;
private Quaternion _blendFromRot;
private Vector3 _blendFromAim; // PD #724 전환 시작 시점의 화면 중앙 주시점(월드) — 전환 중 회전은 이 점과 목표 주시점 사이를 보며 잇는다
private float _blendFromFov;
// ── 검증용 진단 (프레임당 변화량 실측)
private float _lastPosDelta, _lastYawDelta, _maxPosDelta, _maxYawDelta;
/// <summary>직전 프레임 카메라 위치 변화량(m).</summary>
public float LastPosDelta { get { return _lastPosDelta; } }
/// <summary>직전 프레임 카메라 요 변화량(도).</summary>
public float LastYawDelta { get { return _lastYawDelta; } }
/// <summary>측정 구간 내 프레임당 위치 변화 최대값(m).</summary>
public float MaxPosDelta { get { return _maxPosDelta; } }
/// <summary>측정 구간 내 프레임당 요 변화 최대값(도).</summary>
public float MaxYawDelta { get { return _maxYawDelta; } }
/// <summary>캐릭터 수평 속도(m/s).</summary>
public float CharacterSpeed { get { return _charSpeed; } }
/// <summary>모드 전환 보간 잔여 시간(초). 0 이면 보간 없음.</summary>
public float ModeBlendRemaining { get { return _blendTimer; } }
/// <summary>이번 프레임 주시점(오프셋 제외). 검증용.</summary>
public Vector3 PivotAnchor { get { return _livePivot; } }
/// <summary>현재 적용 중인 룩어헤드 오프셋(월드 · 수평). 검증용.</summary>
public Vector3 LookAheadOffset { get { return _lookAhead * _offsetScale; } }
/// <summary>캐릭터 수평 이동 방향. 검증용.</summary>
public Vector3 CharacterVelocityDir { get { return _charVelocityDir; } }
/// <summary>현재 적용 중인 피치(도 · 가림 상향 포함). 검증용.</summary>
public float CurrentPitchDeg { get { return CurrentPitch; } }
/// <summary>감쇠된 충돌 회피 거리(m). 검증용 (PD #721 ①).</summary>
public float AvoidDistance { get { return _avoidDistance; } }
/// <summary>이번 프레임 캐스팅이 요구한 거리(감쇠 전 · m). 검증용.</summary>
public float RawAvoidDistance { get { return _rawAvoidDistance; } }
/// <summary>가림 상향으로 더해진 피치(도). 검증용 (PD #721 ①-b).</summary>
public float OcclusionPitchBoost { get { return _occlusionPitch; } }
/// <summary>지금 카메라→캐릭터 시선이 막혀 있는가. 검증용 (PD #721).</summary>
public bool IsOccluded { get { return _occludedNow; } }
/// <summary>안전 사각형을 지키려고 오프셋을 줄인 배율(1 = 그대로). 검증용 (PD #721 ②).</summary>
public float OffsetScale { get { return _offsetScale; } }
/// <summary>프레임당 변화량 최대값 측정을 초기화한다.</summary>
public void ResetDiagnostics() { _maxPosDelta = 0f; _maxYawDelta = 0f; }
// ── PD #713 ② 가상패드 입력 방향 추종
private float _moveInputYaw; // 마지막으로 보고받은 이동 입력의 월드 요(도)
private float _moveInputTime = -99f; // 그 보고 시각(초)
private float _desiredYaw; // 이번 프레임 요 추종 목표(도) — 진단용
[Tooltip("이동 입력 보고가 이 시간(초) 안이면 요 목표로 쓴다. 지나면 캐릭터 facing 으로 되돌린다 · 임시 0.25")]
[SerializeField] private float moveInputYawValiditySeconds = 0.25f;
/// <summary>
/// PlayerController 가 매 프레임 이동 입력의 월드 방향을 보고한다(PD #713 ②).
/// 요 추종 목표를 캐릭터 facing 대신 이 방향으로 삼아, 캐릭터 회전 지연·조준 회전이 시야를 끌지 않게 한다.
/// </summary>
public void ReportMoveDirection(Vector3 worldDir)
{
worldDir.y = 0f;
if (worldDir.sqrMagnitude < 0.0001f) return;
_moveInputYaw = Mathf.Atan2(worldDir.x, worldDir.z) * Mathf.Rad2Deg;
_moveInputTime = Time.time;
}
// ── PD #719 ⑤ 가상패드 기준 요 래치
private Vector2 _latchedPadDir;
private float _padRefYawLatched;
private float _padInputTime = -99f;
private bool _padLatchInit;
/// <summary>
/// PlayerController 가 **방향 변환 직전에** 가상패드 원본 입력(화면 기준)을 보고한다 (PD #719 ⑤).
///
/// 패드를 누르고 있는 동안 카메라가 돌면 "패드 방향 → 월드 방향 → 카메라 요 → 다시 패드 기준" 순환이 생겨
/// 같은 패드 방향인데 이동 벡터가 계속 휘어 나간다. 그래서 **누른 시점의 카메라 요를 붙잡아 두고**,
/// 패드 방향이 크게 바뀌거나 손을 뗄 때만 다시 잡는다.
/// </summary>
public void ReportPadInput(Vector2 padInput)
{
if (padInput.sqrMagnitude < 0.000001f) return; // 손을 뗀 프레임 — 유효시간이 지나면 래치가 저절로 풀린다
Vector2 d = padInput.normalized;
bool expired = Time.time - _padInputTime > moveInputYawValiditySeconds;
float relatch = settings != null ? Mathf.Max(settings.inputYawRelatchAngle, 0f) : 30f;
if (!_padLatchInit || expired || Vector2.Angle(_latchedPadDir, d) >= relatch)
{
_latchedPadDir = d;
_padRefYawLatched = transform.eulerAngles.y;
_padLatchInit = true;
}
_padInputTime = Time.time;
}
/// <summary>
/// 가상패드 입력(화면 기준)을 월드 방향으로 바꿀 때 쓸 기준 요(도).
/// ModeFixedYaw 면 모드의 고정 요를, CameraRelative 면 **래치된 카메라 요**를 쓴다 (PD #719 ⑤).
/// </summary>
public float InputReferenceYaw
{
get
{
// PD #715 ① — 모드 전환 보간 중에는 전환 직전 기준을 그대로 쓴다.
// 보간 중 기준이 바뀌면 같은 패드 방향이 매 프레임 다른 월드 방향이 되어 캐릭터가 휘어 나간다.
if (_blendTimer > 0f) return _inputRefYawLatched;
var m = Mode;
if (m != null && m.padReference == PadReference.ModeFixedYaw) return m.yaw;
if (_padLatchInit && Time.time - _padInputTime <= moveInputYawValiditySeconds) return _padRefYawLatched;
return transform.eulerAngles.y;
}
}
private float _inputRefYawLatched; // 모드 전환 보간 동안 고정해 두는 입력 기준 요
/// <summary>이번 프레임 요 추종 목표(도). 검증용.</summary>
public float DesiredFollowYaw { get { return _desiredYaw; } }
/// <summary>이동 입력 보고가 유효한가(요 목표로 쓰이는가). 검증용.</summary>
public bool HasMoveInputYaw { get { return Time.time - _moveInputTime <= moveInputYawValiditySeconds; } }
/// <summary>현재 적용 중인 화면 세로 앵커(보간 결과). 검증용.</summary>
public float CurrentAnchorY { get { return _anchorY; } }
/// <summary>숄더뷰에서 현재 적용 중인 요(도). 검증용.</summary>
public float CurrentFollowYaw { get { return _followYaw; } }
/// <summary>캐릭터가 카메라 쪽을 보는 정도 0(전방)~1(카메라 쪽). 검증용.</summary>
public float FacingTowardCamera01 { get { return ComputeFacingToward01(); } }
public int ModeIndex { get { return modeIndex; } }
public FollowCameraSettings Settings { get { return settings; } }
public string CurrentModeLabel
{
get
{
var m = settings != null ? settings.GetMode(modeIndex) : null;
return m != null ? m.label : (modeIndex + 1).ToString();
}
}
public void SetTarget(Transform t) { target = t; CacheRenderers(); SnapToTarget(); }
private void Awake()
{
_cam = GetComponent<Camera>();
if (target == null && !string.IsNullOrEmpty(fallbackTargetTag))
{
var go = GameObject.FindGameObjectWithTag(fallbackTargetTag);
if (go != null) target = go.transform;
}
if (settings != null) modeIndex = Mathf.Clamp(settings.startModeIndex, 0, Mathf.Max(settings.ModeCount - 1, 0));
CacheRenderers();
ApplyCharacterVisibility(); // 모드에 맞는 표시 상태로 복원(잔재 방지)
SnapToTarget();
}
private void CacheRenderers()
{
Transform root = characterModelRoot != null ? characterModelRoot : target;
_characterRenderers = root != null ? root.GetComponentsInChildren<Renderer>(true) : null;
}
// ───────────────────────────────── 모드
/// <summary>모드 순환. 현재 구성 = 1 숄더뷰 → 2 근거리 → 1</summary>
public int CycleMode()
{
int count = settings != null ? Mathf.Max(settings.ModeCount, 1) : 1;
SetMode((modeIndex + 1) % count);
return modeIndex;
}
public void SetMode(int index)
{
int count = settings != null ? Mathf.Max(settings.ModeCount, 1) : 1;
int newIndex = Mathf.Clamp(index, 0, count - 1);
float blend = settings != null ? settings.modeBlendSeconds : 0f;
// PD #709 ③-d — 재생 중 모드가 실제로 바뀌면 즉시 점프하지 않고 시간에 걸쳐 잇는다.
if (Application.isPlaying && blend > 0.001f && modeIndex != newIndex)
{
if (_cam == null) _cam = GetComponent<Camera>();
_inputRefYawLatched = InputReferenceYaw; // PD #715 ① — 전환 직전 기준을 붙잡아 둔다
_blendFromPos = transform.position;
_blendFromRot = transform.rotation;
_blendFromAim = transform.position + transform.forward * AimDistance(); // PD #724 — 모드 바꾸기 전(구 모드 거리) 기준
_blendFromFov = _cam != null ? _cam.fieldOfView : 0f;
modeIndex = newIndex;
ApplyCharacterVisibility();
var mm = Mode;
if (_cam != null && mm != null && mm.nearClipPlane > 0f) _cam.nearClipPlane = mm.nearClipPlane; // 화각만 보간, 클리핑은 즉시
_blendTimer = blend;
_avoidDistance = -1f; // 모드마다 기준 거리가 다르다(8 ↔ 15) — 새 모드 기준으로 다시 잡는다
_avoidDistanceVel = 0f;
return;
}
modeIndex = newIndex;
ApplyLens();
ApplyCharacterVisibility();
SnapToTarget();
}
private CameraModeEntry Mode
{
get { return settings != null ? settings.GetMode(modeIndex) : null; }
}
private void ApplyLens()
{
if (_cam == null) _cam = GetComponent<Camera>();
var m = Mode;
if (_cam == null || m == null) return;
if (m.fieldOfView > 0f) _cam.fieldOfView = m.fieldOfView;
if (m.nearClipPlane > 0f) _cam.nearClipPlane = m.nearClipPlane;
}
private void ApplyCharacterVisibility()
{
var m = Mode;
bool hide = m != null && m.kind == CameraKind.FirstPerson && m.hideCharacterInFirstPerson;
// 첫 호출(_charHiddenState == -1)은 캐시와 무관하게 항상 반영해 잔재 상태를 복원한다.
if (_charHiddenState == (hide ? 1 : 0)) return;
_charHiddenState = hide ? 1 : 0;
if (_characterRenderers == null) return;
for (int i = 0; i < _characterRenderers.Length; i++)
{
var r = _characterRenderers[i];
if (r == null) continue;
r.shadowCastingMode = hide
? UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly
: UnityEngine.Rendering.ShadowCastingMode.On;
}
}
// ───────────────────────────────── 지오메트리
/// <summary>
/// 캐릭터를 화면 세로 screenAnchorY 지점에 놓기 위해 카메라를 자기 상방으로 밀어내는 거리.
/// 화면 중앙(0.5) 대비 (0.5 - anchor) 비율 × 그 거리에서의 화면 세로 크기.
/// </summary>
private float FocusShift(float dist)
{
if (_cam == null) return 0f;
float halfHeight = dist * Mathf.Tan(_cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
return (0.5f - _anchorY) * 2f * halfHeight;
}
/// <summary>
/// 캐릭터가 카메라 쪽(화면 아래쪽)을 보는 정도. 0 = 카메라 전방(화면 위쪽), 1 = 카메라 쪽.
/// 카메라의 **현재 요**를 쓴다 — 모드 1 처럼 요가 도는 모드에서도 맞게 나온다.
/// </summary>
private float ComputeFacingToward01()
{
var m = Mode;
if (target == null || m == null) return 0f;
float camYaw = m.kind == CameraKind.ThirdPersonFollow ? _followYaw : m.yaw;
Vector3 charFwd = Vector3.ProjectOnPlane(target.forward, Vector3.up);
Vector3 camFwd = Vector3.ProjectOnPlane(Quaternion.Euler(0f, camYaw, 0f) * Vector3.forward, Vector3.up);
if (charFwd.sqrMagnitude < 0.0001f || camFwd.sqrMagnitude < 0.0001f) return 0f;
float dot = Vector3.Dot(charFwd.normalized, camFwd.normalized); // +1 카메라 전방 · -1 카메라 쪽
return Mathf.Clamp01((1f - dot) * 0.5f);
}
/// <summary>바라보는 방향에 따라 앵커를 보간하고 감쇠 적용. 두 모드 공통(1인칭만 제외).</summary>
private void UpdateAnchor(float dt)
{
var m = Mode;
if (m == null) return;
float wanted;
bool follow = settings != null && settings.anchorFollowsFacing && m.kind != CameraKind.FirstPerson;
if (follow) wanted = Mathf.Lerp(m.screenAnchorY, m.screenAnchorYTowardCamera, ComputeFacingToward01());
else wanted = m.screenAnchorY;
float smooth = settings != null ? settings.anchorSmoothTime : 0.4f;
if (_anchorY < 0f || dt <= 0f || smooth <= 0.001f) { _anchorY = wanted; _anchorVelocity = 0f; return; }
_anchorY = Mathf.SmoothDamp(_anchorY, wanted, ref _anchorVelocity, smooth, Mathf.Infinity, dt);
}
/// <summary>
/// 숄더뷰의 요를 이동 방향으로 추종시킨다 (PD #719 ① 재설계).
///
/// 게이트를 겹쳐 쓰던 구 방식(데드존 → 급반전 유예 → 반전 스냅 상태기 → 감쇠 → 각속도 상한)은
/// 서로 싸우면서 "멈췄다 갑자기 따라잡는" 체감을 만들었다. 이제 **연속 감쇠 하나**만 쓴다.
/// 각도차가 클수록 시간상수를 줄여(0° = Near, 180° = Far) 큰 각은 알아서 빨리 돈다 — 상태기가 필요 없다.
/// 남긴 게이트는 "정지 중에는 요를 유지" 하나뿐이다.
/// </summary>
private void UpdateFollowYaw(float dt)
{
var m = Mode;
if (target == null || m == null || m.kind != CameraKind.ThirdPersonFollow) return;
// PD #713 ② — 요 목표 = 가상패드 입력 방향(월드). 보고가 끊기면 캐릭터 facing 으로 되돌린다.
float wanted = (m.followYawFromMoveInput && HasMoveInputYaw) ? _moveInputYaw : target.eulerAngles.y;
_desiredYaw = wanted;
if (!_followYawInit || dt <= 0f)
{
_followYaw = wanted; _followYawVelocity = 0f; _followYawInit = true;
_lastTargetPos = target.position;
return;
}
// PD #719 ③ — 모드 전환 보간 중에는 갱신을 멈춘다(전환 보간과 추종이 합성되면 튄다)
if (_blendTimer > 0f) { _followYawVelocity = 0f; return; }
// 유일하게 남긴 게이트 — 제자리에서 방향만 트는 동안은 시야를 흔들지 않는다
if (_charSpeed < m.followMoveSpeedThreshold) { _followYawVelocity = 0f; return; }
float diff = Mathf.DeltaAngle(_followYaw, wanted);
float a = Mathf.Abs(diff);
// 데드존은 기본 0(게이트 제거). 미세 떨림이 보일 때만 2° 정도로 쓴다.
float dead = Mathf.Max(m.followYawDeadzone, 0f);
if (dead > 0f && a <= dead) { _followYawVelocity = 0f; return; }
// 각도차 → 시간상수 선형 보간 (0° = Near 느긋 · 180° = Far 빠름)
float k = Mathf.Clamp01(a / 180f);
float smooth = Mathf.Max(Mathf.Lerp(m.followYawSmoothTimeNear, m.followYawSmoothTimeFar, k), 0.0001f);
// PD #724 — 각속도 상한(도/초). 위치를 누르던 스파이크 상한(#721 ③)은 궤도 모드에서 회전과 위치를 갈라놓아
// 캐릭터가 카메라 뒤로 빠졌다(#722 실측: 유지-하 화면안 20.8% · 뷰포트 x -332). 속도 제한은 여기 회전 한 곳에서만 건다.
float maxSpeed = m.followYawMaxSpeed > 0.001f ? m.followYawMaxSpeed : Mathf.Infinity;
_followYaw = Mathf.SmoothDampAngle(_followYaw, wanted, ref _followYawVelocity, smooth, maxSpeed, dt);
}
/// <summary>
/// 궤도 중심 갱신 (PD #722 ③). 캐릭터 주시점을 positionSmoothTime 으로 감쇠 추종한다.
/// 카메라는 이 점을 중심으로 도는 원호 위에 놓이므로, 위치 감쇠가 반지름을 갉아먹지 않는다.
/// </summary>
private void UpdatePivot(float dt)
{
var m = Mode;
if (m == null || target == null) return;
Vector3 wanted = HeadPoint(m);
float smooth = m.positionSmoothTime;
if (!_smoothPivotInit || dt <= 0f || smooth <= 0.001f)
{
_smoothPivot = wanted; _smoothPivotVel = Vector3.zero; _smoothPivotInit = true; return;
}
_smoothPivot = Vector3.SmoothDamp(_smoothPivot, wanted, ref _smoothPivotVel, smooth, Mathf.Infinity, dt);
}
/// <summary>캐릭터 수평 속도를 잰다.</summary>
private void UpdateCharacterSpeed(float dt)
{
if (target == null || dt <= 0f) return;
Vector3 move = target.position - _lastTargetPos;
move.y = 0f;
_charSpeed = move.magnitude / dt;
_charVelocityDir = move.sqrMagnitude > 0.0000001f ? move.normalized : _charVelocityDir;
_lastTargetPos = target.position;
}
/// <summary>
/// 이동 중 피치 하향 (PD #717 ②). 세로 출렁임을 만들어 PD #719 ④ 로 **기본 off** —
/// FollowCameraSettings.usePitchMoving 을 켤 때만 동작한다.
/// </summary>
private void UpdatePitch(float dt)
{
var m = Mode;
if (m == null) return;
float wanted = m.pitch;
bool on = settings != null && settings.usePitchMoving && m.pitchMoving > 0.001f;
if (on)
{
float refSpeed = Mathf.Max(settings.pitchSpeedReference, 0.0001f);
float k = Mathf.Clamp01(_charSpeed / refSpeed);
wanted = Mathf.Lerp(m.pitch, m.pitchMoving, k);
}
float smooth = settings != null ? settings.pitchBlendSmoothTime : 0.4f;
if (_pitch < 0f || dt <= 0f || smooth <= 0.001f) { _pitch = wanted; _pitchVel = 0f; return; }
_pitch = Mathf.SmoothDamp(_pitch, wanted, ref _pitchVel, smooth, Mathf.Infinity, dt);
}
/// <summary>
/// 가림 시 피치 상향 (PD #721 ①-b). 카메라를 당기는 것만으로 시선이 안 풀리면 위에서 내려다본다.
/// 판정은 **직전 프레임 카메라 위치** 기준이다 — 피치가 위치를 정하고 위치가 피치를 정하는 순환을 끊는다.
/// </summary>
private void UpdateOcclusionPitch(float dt)
{
var m = Mode;
float wanted = 0f;
_occludedNow = false;
if (m != null && m.avoidGeometry && target != null && settings != null && settings.occlusionPitchBoostMax > 0.001f)
{
_occludedNow = IsLineBlocked(transform.position, HeadPoint(m));
if (_occludedNow) wanted = settings.occlusionPitchBoostMax;
}
float smooth = settings != null ? settings.occlusionPitchSmoothTime : 0.25f;
if (dt <= 0f || smooth <= 0.001f) { _occlusionPitch = wanted; _occlusionPitchVel = 0f; return; }
_occlusionPitch = Mathf.SmoothDamp(_occlusionPitch, wanted, ref _occlusionPitchVel, smooth, Mathf.Infinity, dt);
}
/// <summary>카메라에서 화면 중앙 주시점까지의 거리(m). 가림 회피가 적용 중이면 그 거리, 아니면 모드 거리 (PD #724 전환 보간용).</summary>
private float AimDistance()
{
var m = Mode;
float d = _avoidDistance >= 0f ? _avoidDistance : (m != null ? m.distance : 0f);
return Mathf.Max(d, 0.01f);
}
/// <summary>지금 적용 중인 피치(도 · 가림 상향 포함). 미초기화면 모드 값.</summary>
private float CurrentPitch
{
get
{
var m = Mode; if (m == null) return 0f;
float p = _pitch < 0f ? m.pitch : _pitch;
return Mathf.Clamp(p + _occlusionPitch, 0f, 85f);
}
}
/// <summary>카메라가 바라보는 캐릭터 기준점(월드).</summary>
private Vector3 HeadPoint(CameraModeEntry m)
{
if (target == null || m == null) return Vector3.zero;
return m.kind == CameraKind.ThirdPersonFollow
? target.position + Vector3.up * m.followPivotHeight
: target.position + m.lookAtOffset;
}
/// <summary>두 점 사이가 지형·나무·바위로 막혀 있는가(추적 대상 자신의 콜라이더는 제외).</summary>
private bool IsLineBlocked(Vector3 from, Vector3 to)
{
Vector3 dir = to - from;
float d = dir.magnitude;
if (d <= 0.01f) return false;
dir /= d;
int count = Physics.RaycastNonAlloc(from, dir, _hitBuffer, d, obstacleMask, QueryTriggerInteraction.Ignore);
for (int i = 0; i < count; i++)
{
var h = _hitBuffer[i];
if (h.collider == null) continue;
if (h.distance <= 0.0001f) continue;
if (target != null && (h.transform == target || h.transform.IsChildOf(target))) continue;
if (h.collider is CharacterController) continue; // PD #736 — 몬스터·캐릭터 몸통은 가림으로 치지 않는다
return true;
}
return false;
}
/// <summary>
/// 룩어헤드 (PD #712 ①). 주시점을 이동 속도 방향으로 속도에 비례해 민다.
/// 최대 거리는 **거리비 스케일**을 거친 값을 쓴다 (PD #719 보충 · FollowCameraSettings.EffectiveLookAhead).
/// </summary>
private void UpdateLookAhead(float dt)
{
// PD #719 ③ — 모드 전환 보간 중에는 갱신을 멈춘다(전환 보간과 합성 방지)
if (_blendTimer > 0f) return;
var m = Mode;
Vector3 wanted = Vector3.zero;
float maxDist = settings != null ? settings.EffectiveLookAhead(modeIndex) : 0f;
bool on = settings != null && settings.lookAheadEnabled && m != null && maxDist > 0.0001f;
if (on)
{
// 방향 자체도 감쇠한다 — 급전환 때 오프셋이 화면을 가로질러 휘두르는 것을 막는다(PD 긴급 피드백 ①)
float ds = settings.lookAheadDirSmoothTime;
if (dt > 0f && ds > 0.001f)
_lookAheadDir = Vector3.SmoothDamp(_lookAheadDir, _charVelocityDir, ref _lookAheadDirVel, ds, Mathf.Infinity, dt);
else _lookAheadDir = _charVelocityDir;
if (_lookAheadDir.sqrMagnitude > 0.0001f) _lookAheadDir.Normalize(); else _lookAheadDir = _charVelocityDir;
float refSpeed = Mathf.Max(settings.lookAheadSpeedReference, 0.0001f);
float k = Mathf.Clamp01(_charSpeed / refSpeed);
wanted = _lookAheadDir * (k * maxDist);
}
float smooth = settings != null ? settings.lookAheadSmoothTime : 0.7f;
if (dt <= 0f || smooth <= 0.001f) { _lookAhead = wanted; _lookAheadVel = Vector3.zero; return; }
_lookAhead = Vector3.SmoothDamp(_lookAhead, wanted, ref _lookAheadVel, smooth, Mathf.Infinity, dt);
}
/// <summary>
/// 충돌 회피 거리를 **감쇠해서** 갱신한다 (PD #721 ①-a).
///
/// 구 동작은 매 프레임 SphereCast 결과를 그대로 썼다. 나무·바위를 스치듯 지나가면 히트/미스가 프레임마다
/// 갈려 카메라 거리가 15m ↔ 3m 로 튀었다(PD 조작 25초 실측 = 최소 3.21m·평균 7.97m·62%가 10m 미만,
/// 카메라 속도 최대 68m/s). 이제 목표 거리만 캐스팅으로 구하고, 실제 거리는 감쇠로 따라간다.
/// </summary>
private void UpdateAvoidDistance(float dt)
{
var m = Mode;
if (m == null) return;
float wanted = m.distance;
if (m.avoidGeometry)
{
Quaternion rot = Quaternion.Euler(CurrentPitch, m.kind == CameraKind.ThirdPersonFollow ? _followYaw : m.yaw, 0f);
Vector3 pivot = HeadPoint(m);
Vector3 dir = -(rot * Vector3.forward);
// PD #736 — 특정 지역에서 카메라가 멋대로 줌인되던 원인: 구 경로 캐스트(반지름 0.3 구)가 나무 크라운 박스 등
// **시야를 가리지 않는** 물체를 스치기만 해도 카메라를 당겼다(격자 실측 255점 중 88점).
// 이제 먼저 **본래 거리의 카메라 자리에서 캐릭터 머리가 실제로 보이는지**(얇은 레이) 확인하고,
// 보이면 당기지 않는다. 가려질 때만 구 캐스트로 당길 거리를 구한다. 나무는 CameraIgnore 레이어로 마스크에서 제외.
Vector3 fullPos = pivot + dir * m.distance;
if (IsLineBlocked(fullPos, pivot))
{
int count = Physics.SphereCastNonAlloc(pivot, m.collisionPadding, dir, _hitBuffer, m.distance, obstacleMask, QueryTriggerInteraction.Ignore);
bool ignoreOverlap = settings == null || settings.ignoreInitialOverlap;
float nearest = m.distance;
for (int i = 0; i < count; i++)
{
var h = _hitBuffer[i];
if (h.collider == null) continue;
if (ignoreOverlap && h.distance <= 0.0001f) continue;
if (target != null && (h.transform == target || h.transform.IsChildOf(target))) continue;
if (h.collider is CharacterController) continue; // 몬스터 몸통은 무시
if (h.distance < nearest) nearest = h.distance;
}
float floor = Mathf.Max(m.collisionPadding, settings != null ? settings.minCollisionDistance : 0f);
wanted = Mathf.Clamp(nearest, Mathf.Min(floor, m.distance), m.distance);
}
}
_rawAvoidDistance = wanted;
float smooth = settings != null ? settings.occlusionDistanceSmoothTime : 0.25f;
if (_avoidDistance < 0f || dt <= 0f || smooth <= 0.001f) { _avoidDistance = wanted; _avoidDistanceVel = 0f; return; }
_avoidDistance = Mathf.SmoothDamp(_avoidDistance, wanted, ref _avoidDistanceVel, smooth, Mathf.Infinity, dt);
}
/// <summary>
/// 카메라의 목표 위치·회전. offsetScale 은 룩어헤드·전방 포커스 오프셋에만 곱한다 (PD #721 ② 안전 사각형용).
/// </summary>
private bool ComputeDesired(float offsetScale, out Vector3 pos, out Quaternion rot)
{
pos = transform.position; rot = transform.rotation;
var m = Mode;
if (target == null || m == null) return false;
if (m.kind == CameraKind.FirstPerson)
{
rot = Quaternion.Euler(m.firstPersonPitch, target.eulerAngles.y, 0f);
pos = target.position + target.rotation * m.firstPersonEyeOffset;
return true;
}
float dist = _avoidDistance >= 0f ? _avoidDistance : m.distance;
Vector3 look = _lookAhead * offsetScale;
float shift = FocusShift(dist) * offsetScale;
if (m.kind == CameraKind.ThirdPersonFollow)
{
// PD #722 — **캐릭터를 중심으로 한 궤도 회전**.
// 궤도 중심은 감쇠 추종한 캐릭터 주시점이고, 카메라는 그 점에서 정확히 거리만큼 떨어진 원호 위에 놓는다.
// (위치를 월드에서 SmoothDamp 하면 요가 도는 동안 원호의 현을 가로질러 반지름이 줄어든다 —
// 그래서 위치 감쇠를 **피벗에만** 걸고 카메라는 궤도 위에 직접 놓는다. 요 감쇠가 곧 회전 감쇠다)
rot = Quaternion.Euler(CurrentPitch, _followYaw, 0f);
_livePivot = HeadPoint(m);
Vector3 orbitCenter = _smoothPivotInit ? _smoothPivot : _livePivot;
Vector3 orbitPivot = orbitCenter + look + (rot * Vector3.right) * (m.followShoulderOffsetX * offsetScale);
pos = orbitPivot - (rot * Vector3.forward) * dist + (rot * Vector3.up) * shift;
return true;
}
rot = Quaternion.Euler(CurrentPitch, m.yaw, 0f);
_livePivot = HeadPoint(m);
Vector3 pivot = _livePivot + look;
if (settings != null && settings.focusDirection == FocusDirection.CharacterFacing)
{
Vector3 fwd = Vector3.ProjectOnPlane(target.forward, Vector3.up);
if (fwd.sqrMagnitude > 0.0001f) pivot += fwd.normalized * shift;
pos = pivot - (rot * Vector3.forward) * dist;
}
else
{
pos = pivot - (rot * Vector3.forward) * dist + (rot * Vector3.up) * shift;
}
return true;
}
/// <summary>주어진 카메라 자세에서 월드 점의 뷰포트 좌표. 카메라 뒤면 false.</summary>
private bool ViewportOf(Vector3 camPos, Quaternion camRot, Vector3 world, out Vector2 vp)
{
vp = Vector2.zero;
if (_cam == null) return false;
Vector3 local = Quaternion.Inverse(camRot) * (world - camPos);
if (local.z <= 0.01f) return false;
float halfH = Mathf.Tan(_cam.fieldOfView * 0.5f * Mathf.Deg2Rad) * local.z;
float halfW = halfH * Mathf.Max(_cam.aspect, 0.0001f);
vp = new Vector2(0.5f + local.x / (2f * halfW), 0.5f + local.y / (2f * halfH));
return true;
}
/// <summary>
/// 캐릭터가 안전 사각형 밖으로 나가지 않도록 오프셋 배율을 구한다 (PD #721 ②).
/// 오프셋(룩어헤드·포커스)이 뷰포트 좌표를 거의 선형으로 밀기 때문에, 배율 0 과 1 두 지점만 재고
/// 경계에 닿는 배율을 바로 푼다(반복 캐스팅 불필요).
/// </summary>
private float SolveOffsetScale()
{
if (settings == null || !settings.viewportClampEnabled) return 1f;
var m = Mode;
if (m == null || m.kind == CameraKind.FirstPerson || _cam == null) return 1f;
Vector3 p0, p1; Quaternion r0, r1;
if (!ComputeDesired(0f, out p0, out r0)) return 1f;
if (!ComputeDesired(1f, out p1, out r1)) return 1f;
// PD 긴급 피드백 ② — 머리·발 두 점 모두 안전 사각형 안에 들어와야 한다
Vector3 head = HeadPoint(m);
Vector3 feet = target.position;
Vector2 lo = settings.safeViewportMin, hi = settings.safeViewportMax;
float k = 1f;
for (int i = 0; i < 2; i++)
{
Vector3 p = i == 0 ? head : feet;
Vector2 v0, v1;
if (!ViewportOf(p0, r0, p, out v0)) return 0f;
if (!ViewportOf(p1, r1, p, out v1)) return 0f;
k = Mathf.Min(k, AxisLimit(v0.x, v1.x, lo.x, hi.x));
k = Mathf.Min(k, AxisLimit(v0.y, v1.y, lo.y, hi.y));
}
return Mathf.Clamp01(k);
}
/// <summary>배율 0 일 때 a, 1 일 때 b 로 가는 좌표가 [lo, hi] 를 벗어나지 않는 최대 배율.</summary>
private static float AxisLimit(float a, float b, float lo, float hi)
{
if (b >= lo && b <= hi) return 1f; // 배율 1 이어도 안전 — 줄일 필요 없다
float d = b - a;
if (Mathf.Abs(d) < 0.000001f) return 1f;
float bound = b > hi ? hi : lo;
return Mathf.Clamp01((bound - a) / d);
}
private void LateUpdate()
{
float dt = Time.deltaTime;
Vector3 prevPos = transform.position;
float prevYaw = transform.eulerAngles.y;
UpdateCharacterSpeed(dt);
UpdatePivot(dt);
UpdateAnchor(dt);
UpdateFollowYaw(dt);
UpdateLookAhead(dt);
UpdatePitch(dt);
UpdateOcclusionPitch(dt);
UpdateAvoidDistance(dt);
_offsetScale = SolveOffsetScale();
Vector3 pos; Quaternion rot;
if (!ComputeDesired(_offsetScale, out pos, out rot)) return;
var m = Mode;
if (_blendTimer > 0f)
{
// PD #709 ③-d — 모드 전환 시 위치·각도·화각을 시간에 걸쳐 잇는다(즉시 점프 금지)
float total = settings != null ? Mathf.Max(settings.modeBlendSeconds, 0.0001f) : 0.6f;
_blendTimer -= dt;
float k = Mathf.Clamp01(1f - (_blendTimer / total));
k = k * k * (3f - 2f * k); // smoothstep — 시작·끝을 부드럽게
transform.position = Vector3.Lerp(_blendFromPos, pos, k);
// PD #724 — 전환 중 회전은 Slerp 대신 **주시점을 보는 회전**으로 잇는다.
// 위치와 회전을 따로 보간하면 중간에 캐릭터가 카메라 뒤로 빠진다(#722 실측: 모드전환 화면안 86.6% · 뷰포트 x -322).
// 시작점의 화면 중앙 주시점과 목표 자세의 화면 중앙 주시점을 같은 k 로 섞고, 지금 위치에서 그 점을 본다.
// k=0 이면 전환 직전 회전, k=1 이면 목표 회전과 정확히 같다(연속).
Vector3 aimTo = pos + rot * Vector3.forward * AimDistance();
Vector3 aimDir = Vector3.Lerp(_blendFromAim, aimTo, k) - transform.position;
transform.rotation = aimDir.sqrMagnitude > 0.0001f
? Quaternion.LookRotation(aimDir, Vector3.up)
: Quaternion.Slerp(_blendFromRot, rot, k);
if (_cam != null && m != null && m.fieldOfView > 0f)
_cam.fieldOfView = Mathf.Lerp(_blendFromFov, m.fieldOfView, k);
if (_blendTimer <= 0f) { _blendTimer = 0f; _posVelocity = Vector3.zero; }
}
else
{
// PD #722 ③ — 궤도 모드는 위치를 **다시** 감쇠하지 않는다.
// 감쇠는 이미 궤도 중심(UpdatePivot)과 요(UpdateFollowYaw)에 걸려 있다.
// 여기서 월드 좌표를 또 SmoothDamp 하면 회전 중 원호의 현을 가로질러 반지름(카메라-캐릭터 거리)이 줄어든다.
bool orbit = m != null && m.kind == CameraKind.ThirdPersonFollow;
float smooth = m != null ? m.positionSmoothTime : 0.15f;
Vector3 next = orbit
? pos
: (smooth > 0.001f
? Vector3.SmoothDamp(transform.position, pos, ref _posVelocity, smooth)
: pos);
// PD #721 ③ — 순간이동 방지 상한. 캐릭터 속도 배수 + 하한이라 이동 중 카메라가 멈추지는 않는다.
// (구 동작의 "캐릭터 속도 × 1.8" 추종 상한과 다르다. 그건 PD #719 ② 로 제거했고, 이건 스파이크만 자른다)
// PD #724 — **궤도 모드(모드 1)에는 걸지 않는다.** 위치만 눌리고 회전은 그대로 적용돼 카메라가 옛 자리에서 새 방향을
// 보는 프레임이 생겼다(#722 실측: 유지-하 화면안 20.8%). 궤도 모드의 속도 제한은 요 각속도 상한(followYawMaxSpeed)이 맡고,
// 위치는 항상 궤도 위·캐릭터는 항상 앵커 위치에 놓인다. 모드 2 는 그대로다(PD "이대로 픽스").
if (!orbit && settings != null && dt > 0f && settings.cameraSpeedSpikeFactor > 0f)
{
float cap = Mathf.Max(_charSpeed * settings.cameraSpeedSpikeFactor, settings.cameraSpeedSpikeFloor);
Vector3 step = next - transform.position;
float maxStep = cap * dt;
if (step.magnitude > maxStep && maxStep > 0f)
{
next = transform.position + step.normalized * maxStep;
_posVelocity = Vector3.ClampMagnitude(_posVelocity, cap);
}
}
transform.position = next;
transform.rotation = rot; // 각도는 요 추종 결과를 그대로 적용(모드 2 는 고정각)
}
// 검증용 — 프레임당 변화량 실측
_lastPosDelta = (transform.position - prevPos).magnitude;
_lastYawDelta = Mathf.Abs(Mathf.DeltaAngle(prevYaw, transform.eulerAngles.y));
if (_lastPosDelta > _maxPosDelta) _maxPosDelta = _lastPosDelta;
if (_lastYawDelta > _maxYawDelta) _maxYawDelta = _lastYawDelta;
}
/// <summary>카메라를 즉시 목표 위치로 스냅한다(첫 프레임 튐 방지).</summary>
public void SnapToTarget()
{
ApplyLens();
_anchorY = -1f; // 즉시 목표 앵커로 스냅
UpdateAnchor(0f);
_followYawInit = false; // 즉시 이동 방향으로 스냅
_blendTimer = 0f;
_lookAhead = Vector3.zero; _lookAheadVel = Vector3.zero;
_lookAheadDir = _charVelocityDir; _lookAheadDirVel = Vector3.zero;
_smoothPivotInit = false; _smoothPivotVel = Vector3.zero;
UpdatePivot(0f);
_pitch = -1f; _pitchVel = 0f;
_occlusionPitch = 0f; _occlusionPitchVel = 0f;
_avoidDistance = -1f; _avoidDistanceVel = 0f;
_offsetScale = 1f;
_padLatchInit = false;
UpdateFollowYaw(0f);
UpdatePitch(0f);
UpdateAvoidDistance(0f);
Vector3 pos; Quaternion rot;
if (!ComputeDesired(1f, out pos, out rot)) return;
transform.position = pos;
transform.rotation = rot;
_posVelocity = Vector3.zero;
}
#if UNITY_EDITOR
private void OnValidate()
{
if (!Application.isPlaying && target != null) { _cam = GetComponent<Camera>(); SnapToTarget(); }
}
#endif
}
}