using UnityEngine; [DisallowMultipleComponent] public class MazeMixedUIPanelFollower : MonoBehaviour { public enum PanelFollowMode { None, Wrist, CameraFront, CameraFrontOnShow } [Header("Canvas")] [Tooltip("비워두면 이 오브젝트 또는 부모에서 Canvas를 자동으로 찾습니다. Render Mode는 World Space여야 합니다.")] [SerializeField] private Canvas targetCanvas; [Header("Follow Targets")] [Tooltip("손목 HUD가 따라갈 Transform입니다. 컨트롤러 자체보다 WristUIAnchor 빈 오브젝트를 넣는 것을 추천합니다.")] [SerializeField] private Transform wristTarget; [Tooltip("Guide/Result가 기준으로 삼을 카메라 Transform입니다. XR Origin 안의 Main Camera를 넣으세요.")] [SerializeField] private Transform cameraTarget; [Tooltip("Camera Target이 비어 있으면 Camera.main을 자동으로 찾습니다.")] [SerializeField] private bool autoFindMainCameraIfMissing = true; [Header("Panels In One Canvas")] [SerializeField] private RectTransform hudPanel; [SerializeField] private RectTransform guidePanel; [SerializeField] private RectTransform resultPanel; [Header("Panel Follow Mode")] [SerializeField] private PanelFollowMode hudFollowMode = PanelFollowMode.Wrist; [Tooltip("CameraFrontOnShow: 켜지는 순간 카메라 앞에 배치 후 고정됩니다.")] [SerializeField] private PanelFollowMode guideFollowMode = PanelFollowMode.CameraFrontOnShow; [Tooltip("CameraFrontOnShow: 켜지는 순간 카메라 앞에 배치 후 고정됩니다.")] [SerializeField] private PanelFollowMode resultFollowMode = PanelFollowMode.CameraFrontOnShow; [Header("HUD Wrist Placement")] [Tooltip("손목 기준 위치 오프셋입니다. WristUIAnchor를 쓰면 0,0,0 권장")] [SerializeField] private Vector3 hudWristPositionOffset = Vector3.zero; [Tooltip("손목 기준 회전 오프셋입니다. WristUIAnchor를 쓰면 0,0,0 권장")] [SerializeField] private Vector3 hudWristRotationOffset = Vector3.zero; [Header("Guide Camera Placement")] [SerializeField] private float guideDistance = 1.6f; [SerializeField] private float guideHeightOffset = -0.12f; [SerializeField] private Vector3 guideRotationOffset = Vector3.zero; [Header("Result Camera Placement")] [SerializeField] private float resultDistance = 1.7f; [SerializeField] private float resultHeightOffset = -0.05f; [SerializeField] private Vector3 resultRotationOffset = Vector3.zero; [Header("Billboard")] [Tooltip("Guide/Result가 표시될 때 카메라를 바라보게 합니다. UI가 뒤집혀 보이면 Rotation Offset Y=180을 시도하세요.")] [SerializeField] private bool frontPanelsLookAtCamera = true; [Header("Smooth Follow")] [SerializeField] private bool useSmoothFollow = true; [SerializeField] private float positionSmooth = 14f; [SerializeField] private float rotationSmooth = 14f; [Header("Scale Preset")] [Tooltip("체크하면 시작 시 패널별 월드 스케일을 적용합니다. 이미 크기를 맞췄다면 끄세요.")] [SerializeField] private bool applyScaleOnStart = false; [SerializeField] private Vector3 hudWorldScale = new Vector3(0.0016f, 0.0016f, 0.0016f); [SerializeField] private Vector3 guideWorldScale = new Vector3(0.0024f, 0.0024f, 0.0024f); [SerializeField] private Vector3 resultWorldScale = new Vector3(0.0026f, 0.0026f, 0.0026f); private bool hudWasActive; private bool guideWasActive; private bool resultWasActive; private bool hudPlacedOnce; private bool guidePlacedOnce; private bool resultPlacedOnce; private enum PanelKind { HUD, Guide, Result } private void Reset() { targetCanvas = GetComponent(); if (targetCanvas == null) targetCanvas = GetComponentInParent(); } private void Awake() { ResolveReferences(); if (targetCanvas != null && targetCanvas.renderMode != RenderMode.WorldSpace) { Debug.LogWarning( "MazeMixedUIPanelFollower: Canvas Render Mode는 World Space여야 손목/카메라 앞 위치 제어가 자연스럽습니다.", targetCanvas ); } if (applyScaleOnStart) ApplyScalePreset(); CacheActiveStates(); } private void ResolveReferences() { if (targetCanvas == null) targetCanvas = GetComponent(); if (targetCanvas == null) targetCanvas = GetComponentInParent(); if (autoFindMainCameraIfMissing && cameraTarget == null && Camera.main != null) cameraTarget = Camera.main.transform; } private void LateUpdate() { ResolveReferences(); FollowPanel(hudPanel, hudFollowMode, PanelKind.HUD, ref hudWasActive, ref hudPlacedOnce); FollowPanel(guidePanel, guideFollowMode, PanelKind.Guide, ref guideWasActive, ref guidePlacedOnce); FollowPanel(resultPanel, resultFollowMode, PanelKind.Result, ref resultWasActive, ref resultPlacedOnce); } private void CacheActiveStates() { hudWasActive = hudPanel != null && hudPanel.gameObject.activeInHierarchy; guideWasActive = guidePanel != null && guidePanel.gameObject.activeInHierarchy; resultWasActive = resultPanel != null && resultPanel.gameObject.activeInHierarchy; } private void FollowPanel(RectTransform panel, PanelFollowMode mode, PanelKind kind, ref bool wasActive, ref bool placedOnce) { if (panel == null) return; bool isActive = panel.gameObject.activeInHierarchy; if (!isActive) { wasActive = false; placedOnce = false; return; } if (mode == PanelFollowMode.None) { wasActive = isActive; return; } bool justActivated = !wasActive && isActive; Vector3 targetPosition; Quaternion targetRotation; if (mode == PanelFollowMode.Wrist) { if (wristTarget == null) return; GetWristPose(out targetPosition, out targetRotation); ApplyTransform(panel, targetPosition, targetRotation, useSmoothFollow); } else { if (cameraTarget == null) return; GetCameraFrontPose(kind, out targetPosition, out targetRotation); if (mode == PanelFollowMode.CameraFront) { ApplyTransform(panel, targetPosition, targetRotation, useSmoothFollow); } else if (mode == PanelFollowMode.CameraFrontOnShow) { if (justActivated || !placedOnce) { panel.SetPositionAndRotation(targetPosition, targetRotation); placedOnce = true; } } } wasActive = isActive; } private void GetWristPose(out Vector3 targetPosition, out Quaternion targetRotation) { targetPosition = wristTarget.position + wristTarget.right * hudWristPositionOffset.x + wristTarget.up * hudWristPositionOffset.y + wristTarget.forward * hudWristPositionOffset.z; targetRotation = wristTarget.rotation * Quaternion.Euler(hudWristRotationOffset); } private void GetCameraFrontPose(PanelKind kind, out Vector3 targetPosition, out Quaternion targetRotation) { float distance = kind == PanelKind.Result ? resultDistance : guideDistance; float heightOffset = kind == PanelKind.Result ? resultHeightOffset : guideHeightOffset; Vector3 rotationOffset = kind == PanelKind.Result ? resultRotationOffset : guideRotationOffset; Vector3 forward = cameraTarget.forward; if (forward.sqrMagnitude < 0.001f) forward = Vector3.forward; targetPosition = cameraTarget.position + forward.normalized * distance + Vector3.up * heightOffset; if (frontPanelsLookAtCamera) { Vector3 lookDirection = targetPosition - cameraTarget.position; if (lookDirection.sqrMagnitude < 0.001f) lookDirection = cameraTarget.forward; targetRotation = Quaternion.LookRotation(lookDirection.normalized, Vector3.up); } else { targetRotation = cameraTarget.rotation; } targetRotation *= Quaternion.Euler(rotationOffset); } private void ApplyTransform(RectTransform panel, Vector3 targetPosition, Quaternion targetRotation, bool smooth) { if (smooth) { panel.position = Vector3.Lerp(panel.position, targetPosition, Time.deltaTime * positionSmooth); panel.rotation = Quaternion.Slerp(panel.rotation, targetRotation, Time.deltaTime * rotationSmooth); } else { panel.SetPositionAndRotation(targetPosition, targetRotation); } } private void ApplyScalePreset() { if (hudPanel != null) hudPanel.localScale = hudWorldScale; if (guidePanel != null) guidePanel.localScale = guideWorldScale; if (resultPanel != null) resultPanel.localScale = resultWorldScale; } public void SetWristTarget(Transform target) { wristTarget = target; } public void SetCameraTarget(Transform target) { cameraTarget = target; } public void SnapPanelsNow() { bool previousSmooth = useSmoothFollow; useSmoothFollow = false; FollowPanel(hudPanel, hudFollowMode, PanelKind.HUD, ref hudWasActive, ref hudPlacedOnce); FollowPanel(guidePanel, guideFollowMode, PanelKind.Guide, ref guideWasActive, ref guidePlacedOnce); FollowPanel(resultPanel, resultFollowMode, PanelKind.Result, ref resultWasActive, ref resultPlacedOnce); useSmoothFollow = previousSmooth; } public void PlaceGuideInFrontNow() { ResolveReferences(); if (guidePanel == null || cameraTarget == null) return; GetCameraFrontPose(PanelKind.Guide, out Vector3 position, out Quaternion rotation); guidePanel.SetPositionAndRotation(position, rotation); guidePlacedOnce = true; } public void PlaceResultInFrontNow() { ResolveReferences(); if (resultPanel == null || cameraTarget == null) return; GetCameraFrontPose(PanelKind.Result, out Vector3 position, out Quaternion rotation); resultPanel.SetPositionAndRotation(position, rotation); resultPlacedOnce = true; } #if UNITY_EDITOR private void OnValidate() { guideDistance = Mathf.Max(0.1f, guideDistance); resultDistance = Mathf.Max(0.1f, resultDistance); positionSmooth = Mathf.Max(0.01f, positionSmooth); rotationSmooth = Mathf.Max(0.01f, rotationSmooth); if (hudFollowMode == PanelFollowMode.Wrist && wristTarget == null) Debug.LogWarning("MazeMixedUIPanelFollower: HUD가 Wrist 모드인데 Wrist Target이 비어 있습니다. WristUIAnchor를 연결하세요.", this); if ((guideFollowMode == PanelFollowMode.CameraFront || guideFollowMode == PanelFollowMode.CameraFrontOnShow || resultFollowMode == PanelFollowMode.CameraFront || resultFollowMode == PanelFollowMode.CameraFrontOnShow) && cameraTarget == null && !autoFindMainCameraIfMissing) { Debug.LogWarning("MazeMixedUIPanelFollower: CameraFront 계열 모드를 쓰려면 Camera Target을 연결하세요.", this); } } #endif }