2026.06.24

This commit is contained in:
2026-06-24 17:13:40 +09:00
parent b1e85a5b89
commit d601161390
86 changed files with 10287 additions and 336 deletions

View File

@@ -1,38 +1,115 @@
using UnityEngine;
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을 넣으세요.")]
[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")]
[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("각 체크포인트에 도착했을 때 보여줄 발자국 방향입니다.")]
[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 isCompleted;
private bool isFailed;
private bool hasMemoryPiece;
private bool compassUsed;
private int currentCheckpointIndex;
private void Awake()
{
ResolveReferences();
InitializeCheckpoints();
ResolveMovingWalls();
}
private void Start()
@@ -40,9 +117,37 @@ 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 (isCompleted || isFailed)
if (phase == MazePhase.Completed || phase == MazePhase.Failed)
return;
if (playerTarget == null)
@@ -52,10 +157,37 @@ private void Update()
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<MovingMazeWall>(FindObjectsInactive.Include, FindObjectsSortMode.None);
#else
movingWallsToReset = FindObjectsOfType<MovingMazeWall>(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()
@@ -63,37 +195,475 @@ 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();
MazeDirection direction = GetCheckpointDirection(i);
if (showDebugLogs)
Debug.Log($"MazeGameManager: Checkpoint {i + 1} reached.", this);
if (footprintHint != null)
footprintHint.ShowFootprints(direction);
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)
Complete();
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)
@@ -107,61 +677,38 @@ private float GetDistance(Vector3 a, Vector3 b)
return Vector3.Distance(a, b);
}
private MazeDirection GetCheckpointDirection(int index)
#if UNITY_EDITOR
private void OnValidate()
{
if (checkpointDirections != null &&
index >= 0 &&
index < checkpointDirections.Length)
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)
{
return checkpointDirections[index];
Debug.LogWarning(
"MazeGameManager: Checkpoints 배열과 Checkpoint Directions 배열의 길이가 다릅니다. 각 체크포인트와 방향은 같은 인덱스로 1:1 매칭하는 것을 추천합니다.",
this
);
}
return MazeDirection.None;
}
private void Complete()
{
if (isCompleted || isFailed)
return;
isCompleted = true;
if (footprintHint != null)
footprintHint.ClearFootprints();
if (mazeUI != null)
mazeUI.ShowSuccess();
}
public void Fail()
{
if (isCompleted || isFailed)
return;
isFailed = true;
if (footprintHint != null)
footprintHint.ClearFootprints();
if (mazeUI != null)
mazeUI.ShowFail();
}
public void ResetMaze()
{
isCompleted = false;
isFailed = false;
if (reached == null || reached.Length != (checkpoints != null ? checkpoints.Length : 0))
InitializeCheckpoints();
for (int i = 0; i < reached.Length; i++)
reached[i] = false;
if (footprintHint != null)
footprintHint.ClearFootprints();
if (mazeUI != null)
mazeUI.HideReward();
}
}
#endif
}