813 lines
45 KiB
C#
813 lines
45 KiB
C#
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
|
||
}
|
||
}
|