Files
Echo/Assets/_Project/Scripts/Player/CameraLookAtTarget.cs
T

255 lines
8.9 KiB
C#
Raw Normal View History

2026-07-08 19:53:15 +08:00
using UnityEngine;
using DG.Tweening;
using UnityEngine.SceneManagement;
namespace Player
{
public class CameraLookAtTarget : MonoBehaviour
{
public enum RotationSpace
{
Local = 0,
World = 1
}
[Header("目标")]
[Tooltip("需要被追踪/看向的目标物体。留空可使用“自动按 Tag 寻找”。")]
[SerializeField] private GameObject target;
[Tooltip("当 Target 为空时,自动在场景中按 Tag 寻找目标(适合预设体跨场景复用)。")]
[SerializeField] private bool autoFindTargetByTag = true;
[Tooltip("用于自动寻找的 Tag(例如 Player)。要求目标物体在场景里正确设置 Tag。")]
[SerializeField] private string targetTag = "Player";
[Tooltip("自动寻找的间隔秒数,避免每帧 Find。")]
[SerializeField] private float findInterval = 0.5f;
[Header("旋转对象")]
[Tooltip("实际需要旋转的子物体(例如相机 Pivot)。其 Left(-X) 方向将对准目标。不填则默认使用当前脚本所在物体。")]
[SerializeField] private Transform rotationTarget;
[Header("距离触发")]
[Tooltip("开启后:只有在 Max Distance 距离内才会朝向目标。")]
[SerializeField] private bool useDistance = true;
[Tooltip("距离阈值(单位:米)。")]
[SerializeField] private float maxDistance = 5f;
[Header("更新与空间")]
[Tooltip("旋转使用本地(Local)还是世界(World)空间。相机/子物体在父物体下面时通常用 Local。")]
[SerializeField] private RotationSpace rotationSpace = RotationSpace.Local;
[Tooltip("更新间隔秒数。0=每帧 LateUpdate;例如 0.05=每秒20次。")]
[SerializeField] private float updateInterval = 0f;
[Tooltip("世界 Up 方向,通常保持 Vector3.up。")]
[SerializeField] private Vector3 worldUp = Vector3.up;
[Header("平滑旋转 (DOTween)")]
[Tooltip("看向目标时的旋转过渡时间(秒)。0=瞬间转过去。")]
[SerializeField] private float tweenDuration = 0.15f;
[Tooltip("看向目标时的缓动曲线。")]
[SerializeField] private Ease tweenEase = Ease.OutSine;
[Tooltip("角度小于该值(度)则不触发新的 tween,减少抖动和频繁 Kill。")]
[SerializeField] private float minAngleToTween = 0.2f;
[Tooltip("是否使用不受 Time.timeScale 影响的时间(暂停/慢放时有用)。同时也会影响 Update Interval 与自动寻找的计时。")]
[SerializeField] private bool useUnscaledTime = false;
private Tween currentTween;
private float nextUpdateTime;
private float nextFindTime;
public GameObject Target
{
get => target;
set => target = value;
}
public Transform RotationTarget
{
get => rotationTarget;
set => rotationTarget = value;
}
private void Awake()
{
if (rotationTarget == null)
rotationTarget = transform;
}
private void OnEnable()
{
SceneManager.sceneLoaded += OnSceneLoaded;
TryAutoResolveTarget(force: true);
}
private void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
if (currentTween != null && currentTween.IsActive())
currentTween.Kill();
}
private void OnValidate()
{
if (maxDistance < 0f) maxDistance = 0f;
if (findInterval < 0f) findInterval = 0f;
if (updateInterval < 0f) updateInterval = 0f;
if (tweenDuration < 0f) tweenDuration = 0f;
if (minAngleToTween < 0f) minAngleToTween = 0f;
}
private void LateUpdate()
{
TryAutoResolveTarget(force: false);
if (updateInterval > 0f)
{
float now = useUnscaledTime ? Time.unscaledTime : Time.time;
if (now < nextUpdateTime) return;
nextUpdateTime = now + updateInterval;
}
bool inRange = target != null;
if (inRange && useDistance)
{
Vector3 origin = rotationTarget != null ? rotationTarget.position : transform.position;
float sqr = (target.transform.position - origin).sqrMagnitude;
float maxSqr = maxDistance * maxDistance;
inRange = sqr <= maxSqr;
}
if (inRange)
{
Quaternion desired = GetDesiredRotationToTarget(target.transform);
TweenToRotation(desired, tweenDuration, tweenEase);
}
}
private Quaternion GetDesiredRotationToTarget(Transform t)
{
Vector3 origin = rotationTarget != null ? rotationTarget.position : transform.position;
Vector3 dir = t.position - origin;
if (dir.sqrMagnitude < 0.000001f)
return rotationSpace == RotationSpace.Local ? rotationTarget.localRotation : rotationTarget.rotation;
Quaternion worldRot = GetWorldRotation(dir, worldUp);
if (rotationSpace == RotationSpace.World)
return worldRot;
if (rotationTarget.parent == null)
return worldRot;
return Quaternion.Inverse(rotationTarget.parent.rotation) * worldRot;
}
private void TryAutoResolveTarget(bool force)
{
if (!autoFindTargetByTag) return;
if (target != null)
{
if (!target.scene.IsValid() || !target.scene.isLoaded)
{
target = null;
}
else
{
return;
}
}
float now = Time.realtimeSinceStartup;
if (!force)
{
if (now < nextFindTime) return;
}
nextFindTime = now + findInterval;
target = ResolveByTag();
}
private GameObject ResolveByTag()
{
if (string.IsNullOrWhiteSpace(targetTag))
return null;
try
{
GameObject go = GameObject.FindGameObjectWithTag(targetTag);
if (go != null) return go;
}
catch
{
}
Transform[] all = FindObjectsOfType<Transform>(true);
for (int i = 0; i < all.Length; i++)
{
GameObject go = all[i].gameObject;
if (!go.scene.IsValid() || !go.scene.isLoaded) continue;
if (go.CompareTag(targetTag))
return go;
}
return null;
}
private Quaternion GetWorldRotation(Vector3 dir, Vector3 up)
{
Vector3 left = dir.normalized;
Vector3 right = -left;
Vector3 upN = up.sqrMagnitude < 0.000001f ? Vector3.up : up.normalized;
Vector3 forward = Vector3.Cross(upN, right);
if (forward.sqrMagnitude < 0.000001f)
{
upN = Vector3.Cross(right, Vector3.forward).sqrMagnitude > 0.000001f ? Vector3.forward : Vector3.up;
forward = Vector3.Cross(upN, right);
}
forward.Normalize();
Vector3 orthoUp = Vector3.Cross(right, forward);
return Quaternion.LookRotation(forward, orthoUp);
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (!autoFindTargetByTag) return;
target = null;
TryAutoResolveTarget(force: true);
}
private void TweenToRotation(Quaternion desired, float duration, Ease ease)
{
if (rotationTarget == null)
rotationTarget = transform;
Quaternion current = rotationSpace == RotationSpace.Local ? rotationTarget.localRotation : rotationTarget.rotation;
if (Quaternion.Angle(current, desired) < minAngleToTween)
return;
if (currentTween != null && currentTween.IsActive())
currentTween.Kill();
if (duration <= 0f)
{
SetRotation(desired);
return;
}
currentTween = rotationSpace == RotationSpace.Local
? rotationTarget.DOLocalRotateQuaternion(desired, duration)
: rotationTarget.DORotateQuaternion(desired, duration);
currentTween.SetEase(ease);
currentTween.SetUpdate(useUnscaledTime);
}
private void SetRotation(Quaternion rot)
{
if (rotationSpace == RotationSpace.Local)
rotationTarget.localRotation = rot;
else
rotationTarget.rotation = rot;
}
}
}