using UnityEngine; using UnityEngine.Events; [DisallowMultipleComponent] public class MazeGameManager : MonoBehaviour { private enum MazePhase { SearchingExit, Completed, Failed } [Header("VR Player Reference")] [Tooltip("XR Origin 안의 Main Camera Transform을 넣는 것을 추천합니다.")] [SerializeField] private Transform playerTarget; [SerializeField] private bool autoFindMainCameraIfMissing = true; [Header("Managers")] [SerializeField] private MazeUIManager mazeUI; [SerializeField] private FootprintHintManager footprintHint; [Header("Goal / Exit")] [Tooltip("미로 출구입니다. 출구에 도착하면 기억의 조각을 획득하고 성공 처리됩니다.")] [SerializeField] private Transform goal; [SerializeField] private float goalDistance = 1.5f; [Header("Exit Reward")] [Tooltip("체크하면 출구에 도착했을 때 기억의 조각을 획득한 것으로 처리합니다.")] [SerializeField] private bool giveMemoryPieceAtExit = true; [Tooltip("출구 도착 시 켜거나 보여줄 기억의 조각 연출 오브젝트입니다. 없어도 됩니다.")] [SerializeField] private GameObject memoryPieceRewardObject; [Tooltip("Reset 시 기억의 조각 연출 오브젝트를 꺼둘지 정합니다.")] [SerializeField] private bool hideRewardObjectOnReset = true; [Header("Checkpoints")] [Tooltip("출구까지 가는 길목입니다. 코너/갈림길마다 순서대로 배치하세요.")] [SerializeField] private Transform[] checkpoints; [SerializeField] private float checkpointDistance = 1.5f; [Header("Checkpoint Rules")] [Tooltip("체크하면 Checkpoint1 -> Checkpoint2 -> ... 순서대로만 인정됩니다.")] [SerializeField] private bool enforceCheckpointOrder = true; [Tooltip("체크하면 모든 체크포인트를 지난 뒤에만 Goal 성공이 됩니다.")] [SerializeField] private bool requireAllCheckpointsBeforeGoal = true; [Tooltip("체크포인트에 도착했을 때 다음 길목/출구 방향으로 발자국 힌트를 표시합니다.")] [SerializeField] private bool showHintAtCheckpoint = true; [Tooltip("체크하면 Direction enum보다 다음 체크포인트/출구 위치를 기준으로 발자국을 표시합니다.")] [SerializeField] private bool useTargetBasedHint = true; [Header("Checkpoint Directions")] [Tooltip("useTargetBasedHint가 꺼져 있거나 다음 목표가 없을 때 사용하는 발자국 방향입니다.")] [SerializeField] private MazeDirection[] checkpointDirections; [Header("Hint Settings")] [SerializeField] private int checkpointHintFootprintCount = 6; [SerializeField] private float checkpointHintShowTime = 3f; [Header("Compass Hint")] [Tooltip("나침반이 있으면 1회 출구 경로 힌트를 보여줍니다.")] [SerializeField] private bool hasCompass = true; [SerializeField] private int compassHintFootprintCount = 8; [SerializeField] private float compassHintShowTime = 4f; [Header("Start Guide")] [SerializeField] private bool showStartGuideMessage = true; [TextArea] [SerializeField] private string startGuideMessage = "푸른빛 정원에 들어왔다.\n손목의 나침반을 사용하면 빛나는 발자국 힌트를 볼 수 있다."; [Header("VR Distance Option")] [Tooltip("VR에서는 머리 높이가 달라지므로 보통 Y축 높이는 무시하는 것이 좋습니다.")] [SerializeField] private bool ignoreHeight = true; [Header("Result Buttons / Retry & Exit")] [Tooltip("체크하면 MazeUIManager의 Compass / Retry / Exit 버튼 이벤트를 자동으로 연결합니다. 이 경우 Inspector의 OnClick에 같은 함수를 중복 연결하지 마세요.")] [SerializeField] private bool wireUIButtonsAutomatically = true; [Tooltip("다시하기 버튼을 눌렀을 때 플레이어를 시작 위치로 되돌릴지 정합니다.")] [SerializeField] private bool movePlayerToRetrySpawn = false; [Tooltip("이동시킬 플레이어 루트입니다. XR Origin 루트 오브젝트를 넣는 것을 추천합니다. 비워두면 Player Target을 이동합니다.")] [SerializeField] private Transform playerRootToMoveOnRetry; [Tooltip("다시하기 버튼을 눌렀을 때 이동할 시작 위치입니다.")] [SerializeField] private Transform retrySpawnPoint; [Tooltip("나가기/계속하기 버튼을 누르면 Result/HUD/Guide UI를 숨깁니다.")] [SerializeField] private bool hideUIWhenExitButtonClicked = true; [Tooltip("나가기/계속하기 버튼을 눌렀을 때 꺼둘 오브젝트입니다. 예: MazeRoot, MazeCanvas 등. 없어도 됩니다.")] [SerializeField] private GameObject objectToDisableWhenExit; [Tooltip("나가기/계속하기 버튼 클릭 후 호출할 추가 이벤트입니다. 다음 씬 이동, 다음 스토리 진행 등을 여기에 연결하세요.")] [SerializeField] private UnityEvent onExitButtonClicked = new UnityEvent(); [Header("Moving Walls")] [Tooltip("다시하기/초기화 때 시작 위치로 되돌릴 움직이는 벽들입니다. 비워두면 씬 안의 MovingMazeWall을 자동으로 찾을 수 있습니다.")] [SerializeField] private MovingMazeWall[] movingWallsToReset; [SerializeField] private bool autoFindMovingWallsIfEmpty = true; [SerializeField] private bool resetMovingWallsOnMazeReset = true; [Header("Debug")] [SerializeField] private bool showDebugLogs = true; [SerializeField] private bool drawDebugDistances = true; private MazePhase phase; private bool[] reached; private bool hasMemoryPiece; private bool compassUsed; private int currentCheckpointIndex; private void Awake() { ResolveReferences(); InitializeCheckpoints(); ResolveMovingWalls(); } private void Start() { ResetMaze(); } private void OnEnable() { SetUIEventWiring(true); } private void OnDisable() { SetUIEventWiring(false); } private void SetUIEventWiring(bool addListeners) { if (!wireUIButtonsAutomatically || mazeUI == null) return; // 중복 연결 방지용으로 먼저 제거합니다. mazeUI.OnCompassButtonClicked.RemoveListener(UseCompass); mazeUI.OnRetryButtonClicked.RemoveListener(RetryMaze); mazeUI.OnExitButtonClicked.RemoveListener(ExitMaze); if (!addListeners) return; mazeUI.OnCompassButtonClicked.AddListener(UseCompass); mazeUI.OnRetryButtonClicked.AddListener(RetryMaze); mazeUI.OnExitButtonClicked.AddListener(ExitMaze); } private void Update() { if (phase == MazePhase.Completed || phase == MazePhase.Failed) return; if (playerTarget == null) return; CheckCheckpoints(); CheckGoal(); } private void ResolveReferences() { if (autoFindMainCameraIfMissing && playerTarget == null && Camera.main != null) playerTarget = Camera.main.transform; if (footprintHint != null && playerTarget != null) footprintHint.SetPlayerTarget(playerTarget); } private void ResolveMovingWalls() { if (!autoFindMovingWallsIfEmpty) return; if (movingWallsToReset != null && movingWallsToReset.Length > 0) return; #if UNITY_2023_1_OR_NEWER movingWallsToReset = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); #else movingWallsToReset = FindObjectsOfType(true); #endif } private void InitializeCheckpoints() { int count = checkpoints != null ? checkpoints.Length : 0; reached = new bool[count]; for (int i = 0; i < count; i++) reached[i] = checkpoints[i] == null; } private void CheckCheckpoints() { if (checkpoints == null || checkpoints.Length == 0) return; if (enforceCheckpointOrder) CheckCurrentCheckpointOnly(); else CheckAllCheckpoints(); } private void CheckCurrentCheckpointOnly() { SkipNullCheckpointsInOrder(); if (currentCheckpointIndex >= checkpoints.Length) return; Transform checkpoint = checkpoints[currentCheckpointIndex]; float distance = GetDistance(playerTarget.position, checkpoint.position); if (drawDebugDistances) Debug.DrawLine(playerTarget.position, checkpoint.position, Color.yellow); if (distance <= checkpointDistance) { int reachedIndex = currentCheckpointIndex; reached[reachedIndex] = true; currentCheckpointIndex++; UpdateCheckpointUI(); if (showDebugLogs) Debug.Log($"MazeGameManager: Checkpoint {reachedIndex + 1} reached. NextIndex={currentCheckpointIndex}", this); if (showHintAtCheckpoint) ShowCheckpointHint(reachedIndex); } } private void SkipNullCheckpointsInOrder() { while (checkpoints != null && currentCheckpointIndex < checkpoints.Length && checkpoints[currentCheckpointIndex] == null) { if (showDebugLogs) Debug.Log($"MazeGameManager: Checkpoint {currentCheckpointIndex + 1} is null. It is skipped.", this); reached[currentCheckpointIndex] = true; currentCheckpointIndex++; UpdateCheckpointUI(); } } private void CheckAllCheckpoints() { for (int i = 0; i < checkpoints.Length; i++) { if (reached[i]) continue; if (checkpoints[i] == null) { reached[i] = true; UpdateCheckpointUI(); continue; } float distance = GetDistance(playerTarget.position, checkpoints[i].position); if (drawDebugDistances) Debug.DrawLine(playerTarget.position, checkpoints[i].position, Color.yellow); if (distance <= checkpointDistance) { reached[i] = true; UpdateCheckpointUI(); if (showDebugLogs) Debug.Log($"MazeGameManager: Checkpoint {i + 1} reached.", this); if (showHintAtCheckpoint) ShowCheckpointHint(i); } } } private void ShowCheckpointHint(int checkpointIndex) { if (footprintHint == null) return; if (useTargetBasedHint) { Transform target = GetNextHintTarget(); if (target != null) { if (showDebugLogs) Debug.Log($"MazeGameManager: Show checkpoint hint to {target.name}.", this); footprintHint.ShowFootprintsToTarget(target, checkpointHintFootprintCount, checkpointHintShowTime); return; } } MazeDirection direction = GetCheckpointDirection(checkpointIndex); footprintHint.ShowFootprints(direction, checkpointHintFootprintCount, checkpointHintShowTime); } private void CheckGoal() { if (goal == null) return; if (drawDebugDistances) Debug.DrawLine(playerTarget.position, goal.position, Color.green); if (requireAllCheckpointsBeforeGoal && !AllCheckpointsReached()) return; float distance = GetDistance(playerTarget.position, goal.position); if (distance <= goalDistance) CompleteAtExit(); } public void UseCompass() { if (phase == MazePhase.Completed || phase == MazePhase.Failed) return; if (!hasCompass) { if (mazeUI != null) mazeUI.OnCompassMissing(); return; } if (compassUsed) { if (mazeUI != null) mazeUI.OnCompassAlreadyUsed(); return; } compassUsed = true; bool hintShown = ShowHintToNextTarget(compassHintFootprintCount, compassHintShowTime); if (!hintShown && footprintHint != null) { MazeDirection fallbackDirection = GetCurrentFallbackDirection(); footprintHint.ShowFootprints(fallbackDirection, compassHintFootprintCount, compassHintShowTime); } if (showDebugLogs) Debug.Log("MazeGameManager: Compass used.", this); if (mazeUI != null) { mazeUI.SetCompassUsed(); mazeUI.ShowGuideMessage("나침반이 다음 길목을 가리킨다.\n빛나는 발자국을 따라가 보자."); } } private bool ShowHintToNextTarget(int footprintCount, float showTime) { Transform target = GetNextHintTarget(); if (target == null || footprintHint == null) return false; if (showDebugLogs) Debug.Log($"MazeGameManager: Show hint to target: {target.name}", this); footprintHint.ShowFootprintsToTarget(target, footprintCount, showTime); return true; } private Transform GetNextHintTarget() { if (enforceCheckpointOrder && checkpoints != null) { for (int i = currentCheckpointIndex; i < checkpoints.Length; i++) { if (checkpoints[i] != null && (reached == null || i >= reached.Length || !reached[i])) return checkpoints[i]; } } return goal; } private MazeDirection GetCurrentFallbackDirection() { int index = enforceCheckpointOrder ? currentCheckpointIndex : 0; return GetCheckpointDirection(index); } private bool AllCheckpointsReached() { if (checkpoints == null || checkpoints.Length == 0) return true; if (reached == null || reached.Length != checkpoints.Length) return false; for (int i = 0; i < reached.Length; i++) { if (checkpoints[i] == null) continue; if (!reached[i]) return false; } return true; } private int GetCheckpointCount() { if (checkpoints == null) return 0; int count = 0; for (int i = 0; i < checkpoints.Length; i++) { if (checkpoints[i] != null) count++; } return count; } private int GetReachedCheckpointCount() { if (reached == null || checkpoints == null) return 0; int count = 0; int length = Mathf.Min(reached.Length, checkpoints.Length); for (int i = 0; i < length; i++) { if (checkpoints[i] != null && reached[i]) count++; } return count; } private void UpdateCheckpointUI() { if (mazeUI != null) mazeUI.SetCheckpointProgress(GetReachedCheckpointCount(), GetCheckpointCount()); } private MazeDirection GetCheckpointDirection(int index) { if (checkpointDirections != null && index >= 0 && index < checkpointDirections.Length) return checkpointDirections[index]; return MazeDirection.Forward; } private void CompleteAtExit() { if (phase == MazePhase.Completed || phase == MazePhase.Failed) return; phase = MazePhase.Completed; if (giveMemoryPieceAtExit) hasMemoryPiece = true; if (memoryPieceRewardObject != null) memoryPieceRewardObject.SetActive(true); if (footprintHint != null) footprintHint.ClearFootprints(); UpdateCheckpointUI(); if (showDebugLogs) Debug.Log("MazeGameManager: Maze completed at exit.", this); if (mazeUI != null) { mazeUI.SetMemoryPieceState(hasMemoryPiece); int reachedCheckpointCount = GetReachedCheckpointCount(); int totalCheckpointCount = GetCheckpointCount(); if (giveMemoryPieceAtExit) { mazeUI.ShowSuccessResult( "기억의 조각을 되찾았다!", "별빛 발자국을 따라 미로를 통과했습니다.", hasMemoryPiece, reachedCheckpointCount, totalCheckpointCount ); } else { mazeUI.ShowSuccessResult( "미로 탈출 성공!", "발자국 힌트를 따라 미로 출구에 도착했습니다.", false, reachedCheckpointCount, totalCheckpointCount ); } } } public void Fail() { Fail("길을 잃었다..."); } public void Fail(string message) { if (phase == MazePhase.Completed || phase == MazePhase.Failed) return; phase = MazePhase.Failed; if (footprintHint != null) footprintHint.ClearFootprints(); if (showDebugLogs) Debug.Log($"MazeGameManager: Maze failed. Message={message}", this); if (mazeUI != null) mazeUI.ShowFail(message); } public void RetryMaze() { ResolveReferences(); MovePlayerToRetrySpawnIfNeeded(); ResetMazeStateOnly(); } public void ResetMaze() { ResolveReferences(); ResetMazeStateOnly(); } private void ResetMazeStateOnly() { phase = MazePhase.SearchingExit; hasMemoryPiece = false; compassUsed = false; currentCheckpointIndex = 0; if (reached == null || reached.Length != (checkpoints != null ? checkpoints.Length : 0)) InitializeCheckpoints(); else { for (int i = 0; i < reached.Length; i++) reached[i] = checkpoints != null && i < checkpoints.Length && checkpoints[i] == null; } if (memoryPieceRewardObject != null && hideRewardObjectOnReset) memoryPieceRewardObject.SetActive(false); if (footprintHint != null) footprintHint.ClearFootprints(); ResetMovingWallsIfNeeded(); if (showDebugLogs) Debug.Log("MazeGameManager: Maze reset.", this); if (mazeUI != null) { mazeUI.ResetUI(hasCompass); mazeUI.SetStartObjective(); mazeUI.SetCheckpointProgress(0, GetCheckpointCount()); mazeUI.SetMemoryPieceState(false); if (showStartGuideMessage) mazeUI.ShowGuideMessage(startGuideMessage); } } private void ResetMovingWallsIfNeeded() { if (!resetMovingWallsOnMazeReset) return; ResolveMovingWalls(); if (movingWallsToReset == null) return; for (int i = 0; i < movingWallsToReset.Length; i++) { if (movingWallsToReset[i] != null) movingWallsToReset[i].ResetToInitialState(); } } private void MovePlayerToRetrySpawnIfNeeded() { if (!movePlayerToRetrySpawn || retrySpawnPoint == null) return; Transform targetRoot = playerRootToMoveOnRetry; if (targetRoot == null) targetRoot = playerTarget; if (targetRoot == null) return; targetRoot.SetPositionAndRotation(retrySpawnPoint.position, retrySpawnPoint.rotation); if (showDebugLogs) Debug.Log("MazeGameManager: Player moved to retry spawn point.", this); } public void ExitMaze() { if (footprintHint != null) footprintHint.ClearFootprints(); if (mazeUI != null && hideUIWhenExitButtonClicked) { mazeUI.HideResult(); mazeUI.HideHUD(); mazeUI.HideGuideImmediately(); } if (objectToDisableWhenExit != null) objectToDisableWhenExit.SetActive(false); onExitButtonClicked?.Invoke(); if (showDebugLogs) Debug.Log("MazeGameManager: Exit / Continue button clicked.", this); } public void SetHasCompass(bool value) { hasCompass = value; if (mazeUI != null) { if (compassUsed) mazeUI.SetCompassUsed(); else if (hasCompass) mazeUI.SetCompassAvailable(); else mazeUI.SetCompassNone(); } } public bool HasMemoryPiece() { return hasMemoryPiece; } public bool IsCompassUsed() { return compassUsed; } private float GetDistance(Vector3 a, Vector3 b) { if (ignoreHeight) { a.y = 0f; b.y = 0f; } return Vector3.Distance(a, b); } #if UNITY_EDITOR private void OnValidate() { goalDistance = Mathf.Max(0.01f, goalDistance); checkpointDistance = Mathf.Max(0.01f, checkpointDistance); checkpointHintFootprintCount = Mathf.Max(1, checkpointHintFootprintCount); checkpointHintShowTime = Mathf.Max(0f, checkpointHintShowTime); compassHintFootprintCount = Mathf.Max(1, compassHintFootprintCount); compassHintShowTime = Mathf.Max(0f, compassHintShowTime); if (playerTarget == null && !autoFindMainCameraIfMissing) Debug.LogWarning("MazeGameManager: Player Target이 비어 있습니다. XR Origin 안의 Main Camera를 넣는 것을 추천합니다.", this); if (goal == null) Debug.LogWarning("MazeGameManager: Goal이 비어 있습니다. 출구 오브젝트를 연결하세요.", this); if (mazeUI == null) Debug.LogWarning("MazeGameManager: Maze UI가 비어 있습니다.", this); if (footprintHint == null) Debug.LogWarning("MazeGameManager: Footprint Hint가 비어 있습니다.", this); if (requireAllCheckpointsBeforeGoal && (checkpoints == null || checkpoints.Length == 0)) Debug.LogWarning("MazeGameManager: Require All Checkpoints Before Goal이 켜져 있지만 Checkpoints가 비어 있습니다.", this); if (checkpoints != null && checkpointDirections != null && checkpointDirections.Length > 0 && checkpoints.Length != checkpointDirections.Length) { Debug.LogWarning( "MazeGameManager: Checkpoints 배열과 Checkpoint Directions 배열의 길이가 다릅니다. 각 체크포인트와 방향은 같은 인덱스로 1:1 매칭하는 것을 추천합니다.", this ); } } #endif }