using System.Collections; using System.Collections.Generic; using UnityEngine; [DisallowMultipleComponent] public class FootprintHintManager : MonoBehaviour { [Header("VR Player Reference")] [Tooltip("XR Origin의 Main Camera Transform을 넣는 것을 추천합니다.")] [SerializeField] private Transform playerTarget; [Header("Footprint Prefabs")] [SerializeField] private GameObject leftFootPrefab; [SerializeField] private GameObject rightFootPrefab; [Header("Footprint Settings")] [SerializeField] private int footprintCount = 6; [SerializeField] private float spacing = 0.5f; [SerializeField] private float sideOffset = 0.13f; [SerializeField] private float showTime = 3f; [SerializeField] private float revealInterval = 0.08f; [SerializeField] private bool startWithLeftFoot = true; [SerializeField] private Transform footprintParent; [Header("Effect Lifetime")] [Tooltip("FootprintEffect처럼 발자국 프리팹이 스스로 페이드아웃 후 삭제되는 경우 체크하세요.")] [SerializeField] private bool footprintsAutoDestroy = true; [Tooltip("발자국이 스스로 삭제된 뒤 리스트에서 빈 참조를 정리하기 전까지 기다리는 시간입니다.")] [SerializeField] private float autoDestroyCleanupDelay = 1.5f; [Tooltip("Footprints Auto Destroy가 켜져 있는데 프리팹에 FootprintEffect가 없으면 안전하게 자동 삭제합니다.")] [SerializeField] private bool destroyIfMissingFootprintEffect = true; [Tooltip("FootprintEffect가 없는 프리팹을 강제 삭제할 때 추가로 기다릴 시간입니다.")] [SerializeField] private float fallbackDestroyDelay = 1f; [Header("Footprint Rotation Offset")] [Tooltip("왼발 프리팹 방향이 살짝 어긋난 경우 여기서 보정합니다.")] [SerializeField] private Vector3 leftFootRotationOffset = Vector3.zero; [Tooltip("오른발 프리팹 방향이 살짝 어긋난 경우 여기서 보정합니다.")] [SerializeField] private Vector3 rightFootRotationOffset = Vector3.zero; [Header("Ground Settings")] [SerializeField] private bool useGroundRaycast = true; [Tooltip("가급적 Ground 레이어만 선택하세요. Everything이면 벽이나 다른 오브젝트를 바닥으로 착각할 수 있습니다.")] [SerializeField] private LayerMask groundMask = ~0; [SerializeField] private float raycastHeight = 2f; [SerializeField] private float raycastDistance = 5f; [SerializeField] private float groundOffset = 0.03f; [SerializeField] private float fallbackGroundY = 0f; [Header("Obstacle Settings")] [Tooltip("체크하면 발자국이 Wall 레이어를 뚫고 생성되지 않도록 벽 앞에서 멈춥니다.")] [SerializeField] private bool stopAtObstacle = true; [Tooltip("벽/장애물 레이어만 선택하세요. 예: Wall")] [SerializeField] private LayerMask obstacleMask; [Tooltip("바닥에서 어느 높이에서 벽을 검사할지 정합니다.")] [SerializeField] private float obstacleCheckHeight = 0.45f; [Tooltip("벽 감지 SphereCast 반지름입니다. 발자국 폭보다 작게 두는 것을 추천합니다.")] [SerializeField] private float obstacleCheckRadius = 0.08f; [Tooltip("벽에 너무 붙어서 발자국이 생성되지 않도록 여유 거리를 둡니다.")] [SerializeField] private float obstaclePadding = 0.12f; [Header("Debug")] [SerializeField] private bool showDebugLogs = false; [SerializeField] private bool drawDebugPath = true; [SerializeField] private Color debugPathColor = Color.cyan; [SerializeField] private Color debugBlockedColor = Color.red; private readonly List spawnedFootprints = new List(); private Coroutine footprintRoutine; private Coroutine cleanupRoutine; private void OnDisable() { ClearFootprints(); } public void SetPlayerTarget(Transform target) { playerTarget = target; } public void ShowFootprints(MazeDirection direction) { ShowFootprints(direction, -1, -1f); } public void ShowFootprints(MazeDirection direction, int countOverride, float showTimeOverride) { if (playerTarget == null) { Debug.LogWarning("FootprintHintManager: Player Target is missing.", this); return; } Vector3 moveDirection = GetWorldDirection(direction); ShowFootprintsAlongWorldDirection(moveDirection, countOverride, showTimeOverride); } public void ShowFootprintsToTarget(Transform target) { ShowFootprintsToTarget(target, -1, -1f); } public void ShowFootprintsToTarget(Transform target, int countOverride, float showTimeOverride) { if (target == null) { Debug.LogWarning("FootprintHintManager: Target is missing.", this); return; } ShowFootprintsToPosition(target.position, countOverride, showTimeOverride); } public void ShowFootprintsToPosition(Vector3 targetPosition) { ShowFootprintsToPosition(targetPosition, -1, -1f); } public void ShowFootprintsToPosition(Vector3 targetPosition, int countOverride, float showTimeOverride) { if (playerTarget == null) { Debug.LogWarning("FootprintHintManager: Player Target is missing.", this); return; } Vector3 moveDirection = targetPosition - playerTarget.position; moveDirection.y = 0f; ShowFootprintsAlongWorldDirection(moveDirection, countOverride, showTimeOverride); } public void ShowFootprintsAlongWorldDirection(Vector3 worldDirection) { ShowFootprintsAlongWorldDirection(worldDirection, -1, -1f); } public void ShowFootprintsAlongWorldDirection(Vector3 worldDirection, int countOverride, float showTimeOverride) { worldDirection.y = 0f; if (worldDirection.sqrMagnitude < 0.001f) { if (showDebugLogs) Debug.Log("FootprintHintManager: Direction is too small. Hint skipped.", this); return; } worldDirection.Normalize(); StopRunningRoutines(); footprintRoutine = StartCoroutine( ShowFootprintRoutine(worldDirection, countOverride, showTimeOverride) ); } private IEnumerator ShowFootprintRoutine(Vector3 moveDirection, int countOverride, float showTimeOverride) { ClearSpawnedFootprintsOnly(); if (playerTarget == null) { Debug.LogWarning("FootprintHintManager: Player Target is missing.", this); footprintRoutine = null; yield break; } if (leftFootPrefab == null || rightFootPrefab == null) { Debug.LogWarning("FootprintHintManager: Left Foot Prefab or Right Foot Prefab is missing.", this); footprintRoutine = null; yield break; } int countToUse = countOverride > 0 ? countOverride : footprintCount; float showTimeToUse = showTimeOverride > 0f ? showTimeOverride : showTime; Vector3 origin = playerTarget.position; Vector3 sideDirection = Vector3.Cross(Vector3.up, moveDirection).normalized; Vector3 lastPathCenter = origin; if (showDebugLogs) Debug.Log($"FootprintHintManager: Show footprints. Count={countToUse}, Direction={moveDirection}", this); for (int i = 0; i < countToUse; i++) { bool isLeftFoot = startWithLeftFoot ? i % 2 == 0 : i % 2 != 0; GameObject prefabToUse = isLeftFoot ? leftFootPrefab : rightFootPrefab; float forwardDistance = spacing * (i + 1); float leftRightOffset = isLeftFoot ? -sideOffset : sideOffset; Vector3 pathCenter = origin + moveDirection * forwardDistance; bool blocked = stopAtObstacle && IsBlockedByObstacle(lastPathCenter, pathCenter); if (drawDebugPath) Debug.DrawLine(lastPathCenter + Vector3.up * 0.05f, pathCenter + Vector3.up * 0.05f, blocked ? debugBlockedColor : debugPathColor, 2f); if (blocked) { if (showDebugLogs) Debug.Log($"FootprintHintManager: Footprints stopped by obstacle at index {i}.", this); break; } Vector3 spawnPosition = pathCenter + sideDirection * leftRightOffset; Quaternion spawnRotation = Quaternion.LookRotation(moveDirection, Vector3.up); PlaceOnGround(ref spawnPosition, ref spawnRotation, moveDirection); Vector3 rotationOffset = isLeftFoot ? leftFootRotationOffset : rightFootRotationOffset; Quaternion finalRotation = spawnRotation * Quaternion.Euler(rotationOffset); GameObject footprint = footprintParent != null ? Instantiate(prefabToUse, spawnPosition, finalRotation, footprintParent) : Instantiate(prefabToUse, spawnPosition, finalRotation); spawnedFootprints.Add(footprint); ScheduleFallbackDestroyIfNeeded(footprint, showTimeToUse); lastPathCenter = pathCenter; if (revealInterval > 0f) yield return new WaitForSeconds(revealInterval); } if (showTimeToUse > 0f) yield return new WaitForSeconds(showTimeToUse); if (footprintsAutoDestroy) { cleanupRoutine = StartCoroutine(CleanupDestroyedFootprintsAfterDelay(autoDestroyCleanupDelay)); } else { ClearSpawnedFootprintsOnly(); } footprintRoutine = null; } private void ScheduleFallbackDestroyIfNeeded(GameObject footprint, float showTimeToUse) { if (!footprintsAutoDestroy || !destroyIfMissingFootprintEffect || footprint == null) return; FootprintEffect effect = footprint.GetComponentInChildren(true); if (effect != null) return; Destroy(footprint, Mathf.Max(0f, showTimeToUse + fallbackDestroyDelay)); } private Vector3 GetWorldDirection(MazeDirection direction) { Vector3 forward = playerTarget.forward; Vector3 right = playerTarget.right; forward.y = 0f; right.y = 0f; if (forward.sqrMagnitude > 0.001f) forward.Normalize(); if (right.sqrMagnitude > 0.001f) right.Normalize(); switch (direction) { case MazeDirection.Forward: return forward; case MazeDirection.Backward: return -forward; case MazeDirection.Left: return -right; case MazeDirection.Right: return right; case MazeDirection.None: default: return Vector3.zero; } } private void PlaceOnGround(ref Vector3 position, ref Quaternion rotation, Vector3 moveDirection) { if (!useGroundRaycast) { position.y = fallbackGroundY + groundOffset; return; } Vector3 rayStart = position + Vector3.up * raycastHeight; if (Physics.Raycast(rayStart, Vector3.down, out RaycastHit hit, raycastDistance, groundMask, QueryTriggerInteraction.Ignore)) { position = hit.point + hit.normal * groundOffset; Vector3 projectedForward = Vector3.ProjectOnPlane(moveDirection, hit.normal).normalized; if (projectedForward.sqrMagnitude > 0.001f) rotation = Quaternion.LookRotation(projectedForward, hit.normal); } else { position.y = fallbackGroundY + groundOffset; } } private bool IsBlockedByObstacle(Vector3 from, Vector3 to) { if (!stopAtObstacle) return false; if (obstacleMask.value == 0) return false; Vector3 fromGround = GetGroundPointForObstacleCheck(from); Vector3 toGround = GetGroundPointForObstacleCheck(to); Vector3 start = fromGround + Vector3.up * obstacleCheckHeight; Vector3 end = toGround + Vector3.up * obstacleCheckHeight; Vector3 direction = end - start; direction.y = 0f; float distance = direction.magnitude; if (distance <= 0.001f) return false; direction.Normalize(); float checkDistance = Mathf.Max(0f, distance + obstaclePadding); return Physics.SphereCast(start, obstacleCheckRadius, direction, out RaycastHit hit, checkDistance, obstacleMask, QueryTriggerInteraction.Ignore); } private Vector3 GetGroundPointForObstacleCheck(Vector3 position) { if (!useGroundRaycast) { position.y = fallbackGroundY; return position; } Vector3 rayStart = position + Vector3.up * raycastHeight; if (Physics.Raycast(rayStart, Vector3.down, out RaycastHit hit, raycastDistance, groundMask, QueryTriggerInteraction.Ignore)) return hit.point; position.y = fallbackGroundY; return position; } private IEnumerator CleanupDestroyedFootprintsAfterDelay(float delay) { if (delay > 0f) yield return new WaitForSeconds(delay); spawnedFootprints.RemoveAll(footprint => footprint == null); cleanupRoutine = null; } private void StopRunningRoutines() { if (footprintRoutine != null) { StopCoroutine(footprintRoutine); footprintRoutine = null; } if (cleanupRoutine != null) { StopCoroutine(cleanupRoutine); cleanupRoutine = null; } } private void ClearSpawnedFootprintsOnly() { for (int i = 0; i < spawnedFootprints.Count; i++) { if (spawnedFootprints[i] != null) Destroy(spawnedFootprints[i]); } spawnedFootprints.Clear(); } public void ClearFootprints() { StopRunningRoutines(); ClearSpawnedFootprintsOnly(); } #if UNITY_EDITOR private void OnValidate() { footprintCount = Mathf.Max(1, footprintCount); spacing = Mathf.Max(0.01f, spacing); sideOffset = Mathf.Max(0f, sideOffset); showTime = Mathf.Max(0f, showTime); revealInterval = Mathf.Max(0f, revealInterval); autoDestroyCleanupDelay = Mathf.Max(0f, autoDestroyCleanupDelay); fallbackDestroyDelay = Mathf.Max(0f, fallbackDestroyDelay); raycastHeight = Mathf.Max(0.01f, raycastHeight); raycastDistance = Mathf.Max(0.01f, raycastDistance); groundOffset = Mathf.Max(0f, groundOffset); obstacleCheckHeight = Mathf.Max(0f, obstacleCheckHeight); obstacleCheckRadius = Mathf.Max(0.001f, obstacleCheckRadius); obstaclePadding = Mathf.Max(0f, obstaclePadding); if (stopAtObstacle && obstacleMask.value == 0) { Debug.LogWarning( "FootprintHintManager: Stop At Obstacle이 켜져 있지만 Obstacle Mask가 비어 있습니다. Wall 레이어를 지정하세요.", this ); } if (useGroundRaycast && groundMask.value == ~0) { Debug.LogWarning( "FootprintHintManager: Ground Mask가 Everything입니다. 가능하면 Ground 레이어만 지정하세요.", this ); } } #endif }