본문 바로가기
Unity

Unity(11) - Stage Select & Lobby

by Srff5123 2026. 5. 31.
728x90

저번 글에서 플레이어가 몬스터를 공격했을 때, 몬스터가 플레이어를 공격했을 때의 데미지 이벤트와 UI 출력까지 구현하였다

이번 글에서는 바로 Main 씬에서 바로 게임이 시작되는 구조가 아닌 실제 게임 처럼 만들기 위해

로비를 만들고 로비에서 스테이지 선택을 통해 게임을 시작하도록 만들고자 한다.

 

로비는 기본적으로 플레이어가 자신의 공간을 넓히고 꾸밀 수 있도록 인게임과 동일한 복셀월드를 사용할것이고 

해당 복셀 월드에 인게임과 다르게 게임 시작을 위한 UI를 만들어 스테이지를 선택하고 시작할 수 있도록 할것이다

스테이지는 난이도(맵 크기, 몬스터 수, 스테이지 개수)에 따라 나누어지며 이전 스테이지를 깨면 다음 스테이지가 해금되도록 만들것이며 랜덤 맵 게임의 유형과 멀티 플레이 유형의 게임도 만들것이기에 우선은 UI만 추가할것이다

 

 

1. Stage Select

using System;
using UnityEngine;

[Serializable]
public class StageDefinition
{
    [Header("Basic")]
    public int stageId = 1; // 스테이지 고유 번호 
    public string stageName = "Grassland Defense"; // 스테이지 이름


    [Header("Unlock")]
    [Tooltip("Required cleared stage id. 0 means unlocked from start.")]
    public int requiredClearedStageId = 0;
    // 해당 스테이지를 열기 위해 클리어해야하는 이전 스테이지 정보

    [Header("Preview")]
    public Sprite previewSprite;

    [Header("Voxel World Size")]
    [Min(1)] public int chunksX = 5;
    [Min(1)] public int chunksZ = 5;
    // 맵크기 정보, 

    [Header("World Seed")]
    public int mapSeed = 1001;
    // 맵 시드값, 게임 세이브 또는 다시 시작하는 경우 같은 맵 지형이 나오게끔

    [Header("Defense Info")]
    [Tooltip("Monster count per internal stage.")]
    [Min(0)] public int monsterCount = 10;
    // 내부 스테이지 하나당 등장할 몬스터 수 

    [Tooltip("Internal stage count in this selected stage.")]
    [Min(1)] public int internalStageCount = 5;
    // 내부 스테이지 수 

    public string rewardText = "Stone";
    // 주 보상 텍스트 정보
    // 아직은 클리어 보상 안만들었음 (인게임에 둘 수 있는 꾸미기 아이템 같은 치장 , 스킨) 생각중

    [Header("Difficulty")]
    public int difficulty = 1;
    // 난이도 

    public string MapSizeLabel => $"{chunksX}x{chunksZ}";
    // 맵 크기 문자열 표시
}

 

UI에 표시해줄 스테이지 정보를 담을 StageDefinition 클래스

 

using UnityEngine;


public static class StageProgressStore
{
    private const string HighestClearedStageKey = "MineUnity_HighestClearedStage";
    // 플레이어 정보 저장 키 

    public static int HighestClearedStage
    {
        get => PlayerPrefs.GetInt(HighestClearedStageKey, 0);
        // 저장된 가장 높은 클리어 스테이지 번호
        // 저장된 값이 없다면 기본값 0
        private set
        {
            PlayerPrefs.SetInt(HighestClearedStageKey, value);
            // 가장 높은 클리어 스테이지 값을 저장
            PlayerPrefs.Save();
            // 저장
        }
    }

    public static bool IsStageUnlocked(StageDefinition stage)
    {
        if (stage == null)
            return false;
        // 스테이지 데이터가 없다면 잠긴것으로 처리

        if (stage.requiredClearedStageId <= 0)
            return true;

        return HighestClearedStage >= stage.requiredClearedStageId;
        // 가장 높은 클리어 스테이지 정보를 받아 위의 스테이지 잠금
    }

    public static bool IsStageCleared(StageDefinition stage)
    {
        if (stage == null)
            return false;

        return HighestClearedStage >= stage.stageId;
        // 아래 스테이지 언락 해제 클리어 처리
    }

    public static void MarkStageCleared(int stageId)
    {
        if (stageId > HighestClearedStage)
            HighestClearedStage = stageId;
        // 다시 클리어 해도 값 돌리지 않기
    }

    public static void ResetProgress()
    {
        PlayerPrefs.DeleteKey(HighestClearedStageKey);
        // 저장된 가장 높은 클리어 스테이지 정보 삭제 
        PlayerPrefs.Save();
        // 삭제 후 저장
    }
}

 

플레이어가 어디까지 스테이지를 클리어 했는지에 대해 저장하여 다음 스테이지 해금 조건을 판단하기 위한

StageProgressStore 클래스이다

 

using UnityEngine;

public static class StageSelectionStore
{
    public enum RunMode
    {
        None, // 스테이지 선택 x
        FixedStage, // 일반 스테이지 선택
        RandomMap // 랜덤맵 선택
    }

    public static bool HasSelection { get; private set; }
    // 현재 선택된 스테이지 정보 여부
    public static RunMode Mode { get; private set; }
    // 선택맵, 랜덤맵 구분
    public static int SelectedStageId { get; private set; }
    // 선택한 스테이지 번호
    public static string SelectedStageName { get; private set; }
    // 선택한 스테이지 이름

    public static int ChunksX { get; private set; }
    public static int ChunksZ { get; private set; }
    public static int MapSeed { get; private set; }
    // 메인 씬 복셀월드에 적용할 x,z 청크 수와 맵 시드

    public static int MonsterCountPerInternalStage { get; private set; }
    // 일반 스테이지에서 내부 스테이지 하나마다 스폰될 몬스터의 수

    public static int InternalStageCount { get; private set; }

    public static string RewardText { get; private set; }
    // 선택한 스테이지의 보상 텍스트
    public static int Difficulty { get; private set; }
    // 선택한 스테이지의 난이도 값

    public static bool IsRandomMap => Mode == RunMode.RandomMap;
    // 현재 실행 모드가 랜덤맵인지 확인
    public static bool IsInfiniteInternalStage => IsRandomMap || InternalStageCount < 0;
    // 랜덤맵이거나 스테이지카운트가 음수면 내부 스테이지 무한으로 판단
    public static string MapSizeLabel => $"{ChunksX}x{ChunksZ}";
    // 맵 크기 문자열

    public static void SelectStage(StageDefinition stage)
    {
        if (stage == null)
            return;
        // 잘못된 데이터가 들어오면 아무것도 하지 않는다

        HasSelection = true;
        // 선택된 스테이지 정보 있음
        Mode = RunMode.FixedStage;
        // 일반 스테이지 모드 설정

        SelectedStageId = stage.stageId;
        SelectedStageName = stage.stageName;
        // 선택한 스테이지 번호,이름 저장

        ChunksX = Mathf.Max(1, stage.chunksX);
        ChunksZ = Mathf.Max(1, stage.chunksZ);
        MapSeed = stage.mapSeed;
        // 잘못된 청크 수 보정, 맵시드 저장

        MonsterCountPerInternalStage = Mathf.Max(0, stage.monsterCount);
        InternalStageCount = Mathf.Max(1, stage.internalStageCount);
        // 몬스터와 내부 스테이지 수 보정

        RewardText = stage.rewardText;
        Difficulty = stage.difficulty;
        // 보상, 난이도 텍스트 저장
    }

    public static void SelectRandomMap(int chunksX, int chunksZ, int mapSeed)
    {
        HasSelection = true;
        Mode = RunMode.RandomMap;
        // 랜덤맵 선택

        SelectedStageId = -1;
        SelectedStageName = "Random Map";
        // 랜덤맵 ui, 스테이지따로 없으니 -1로 설정

        ChunksX = Mathf.Clamp(chunksX, 5, 30);
        ChunksZ = Mathf.Clamp(chunksZ, 5, 30);
        MapSeed = mapSeed;
        // 청크 수 제한 및 맵시드 저장

        MonsterCountPerInternalStage = 2;
        // 랜덤맵 기본값 2 저장
        // 실제 계산은 따로 GetMonsterCountForInternalStage에서 설정
        InternalStageCount = -1; // 내부 스테이지 무한

        RewardText = "Random";
        Difficulty = 1;
    }


    public static int GetMonsterCountForInternalStage(int internalStageNumber)
    {
        int safeStage = Mathf.Max(1, internalStageNumber);
        // 내부 스테이지 번호 보정

        if (IsRandomMap)
            return safeStage * 2;
        // 랜덤맵에서는 내부 스테이지에 n * 2 마리씩 스폰

        return Mathf.Max(0, MonsterCountPerInternalStage);
        // 일반 스테이지는 고정 몬스터 수 
    }

    public static void Clear()
    {
        HasSelection = false; // 선택 정보없음
        Mode = RunMode.None; 

        SelectedStageId = 0;
        SelectedStageName = string.Empty;

        ChunksX = 0;
        ChunksZ = 0;
        MapSeed = 0;

        MonsterCountPerInternalStage = 0;
        InternalStageCount = 0;

        RewardText = string.Empty;
        Difficulty = 0;

        // 선택한 스테이지 없는 경우 정보 초기화 
    }
}

 

StageSelectionStore 로비와 메인 씬을 이어주는 임시 저장소로 로비에서 스테이지 선택하면 해당 되는 값이 저장되고

메인씬으로 들어갔을 때 해당 값을 읽어 맵의 크기, 몬스터 수를 적용하여 플레이

using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class StageListItemView : MonoBehaviour
{
    [Header("UI")]
    [SerializeField] private Button button; // 스테이지 카드 클릭
    [SerializeField] private Image backgroundImage; // 배경 이미지
    [SerializeField] private TMP_Text stageTitleText; // 스테이지 이름
    [SerializeField] private GameObject selectedFrame;  // 선택된 카드 테두리
    [SerializeField] private Image selectedFrameImage; // 테두리 이미지
    [SerializeField] private GameObject lockOverlay; // 잠긴 스테이지 
    [SerializeField] private Image lockOverlayImage; // 잠긴 스테이지 이미지 
    [SerializeField] private TMP_Text lockText; // 잠김 상태 텍스트

    [Header("Visual Debug Colors")]
    [SerializeField] private Color normalColor = new Color(1f, 1f, 1f, 0.95f); // 기본 상태
    [SerializeField] private Color selectedColor = new Color(1f, 0.92f, 0.25f, 1f); // 선택
    [SerializeField] private Color lockedColor = new Color(0.75f, 0.75f, 0.75f, 0.9f); // 잠김
    [SerializeField] private Color textColor = Color.black;
    [SerializeField] private Color lockOverlayColor = new Color(0f, 0f, 0f, 0.45f);

    private StageSelectController owner; // 카드 관리 컨트롤러
    private int index; // 리스트 안에서 몇 번째 스테이지 인지 저장
    private StageDefinition stage; // 실제 스테이지 데이터

    private void Awake()
    {
        AutoBindRefs();
    }

    private void Reset()
    {
        AutoBindRefs();
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        AutoBindRefs();
    }
#endif

    public void Initialize(StageSelectController owner, int index, StageDefinition stage)
    {
        AutoBindRefs();

        this.owner = owner; // 컨트롤러 저장
        this.index = index; // 마지막 인덱스 번호 저장
        this.stage = stage; // 스테이지 데이터 저장

        if (button != null)
        {
            button.onClick.RemoveAllListeners(); // 기존에 연결된 이벤트가 있으면 제거
            button.onClick.AddListener(OnClick); // 버튼 클릭 호출
        }

        Refresh(unlocked: false, selected: false); // 초기 표시 갱신
    }

    public void Refresh(bool unlocked, bool selected)
    {
        AutoBindRefs();

        if (stageTitleText != null)
        {
            string lockPrefix = unlocked ? string.Empty : "[Lock] ";
            stageTitleText.text = $"{lockPrefix}Stage {stage.stageId} - {stage.stageName}";
            stageTitleText.color = textColor;
            stageTitleText.raycastTarget = false;
            stageTitleText.alignment = TextAlignmentOptions.Center;
            stageTitleText.fontSize = 30f;
        }

        if (backgroundImage != null)
        {
            backgroundImage.enabled = true;
            backgroundImage.raycastTarget = true;
            backgroundImage.color = selected ? selectedColor : unlocked ? normalColor : lockedColor;
        }

        if (selectedFrame != null)
            selectedFrame.SetActive(selected);

        if (selectedFrameImage != null)
        {
            selectedFrameImage.enabled = true;
            selectedFrameImage.raycastTarget = false;
            selectedFrameImage.color = selectedColor;
        }

        if (lockOverlay != null)
            lockOverlay.SetActive(!unlocked);

        if (lockOverlayImage != null)
        {
            lockOverlayImage.enabled = true;
            lockOverlayImage.raycastTarget = false;
            lockOverlayImage.color = lockOverlayColor;
        }

        if (lockText != null)
        {
            lockText.text = "Lock";
            lockText.color = Color.white;
            lockText.raycastTarget = false;
            lockText.alignment = TextAlignmentOptions.Center;
            lockText.fontSize = 26f;
        }
        // 선택된 스테이지에 따른 UI 정보 초기화
    }

    private void OnClick()
    {
        if (owner == null)
            return;

        owner.OnClickStageItem(index);
    }


    private void AutoBindRefs()
    {
        // 참조 컴포넌트 자동 연결 찾기
        if (button == null)
            button = GetComponent<Button>();

        if (backgroundImage == null)
            backgroundImage = GetComponent<Image>();

        if (stageTitleText == null)
        {
            Transform t = transform.Find("StageTitleText");
            if (t != null)
                stageTitleText = t.GetComponent<TMP_Text>();
        }

        if (selectedFrame == null)
        {
            Transform t = transform.Find("SelectedFrame");
            if (t != null)
                selectedFrame = t.gameObject;
        }

        if (selectedFrameImage == null && selectedFrame != null)
            selectedFrameImage = selectedFrame.GetComponent<Image>();

        if (lockOverlay == null)
        {
            Transform t = transform.Find("LockOverlay");
            if (t != null)
                lockOverlay = t.gameObject;
        }

        if (lockOverlayImage == null && lockOverlay != null)
            lockOverlayImage = lockOverlay.GetComponent<Image>();

        if (lockText == null && lockOverlay != null)
        {
            Transform t = lockOverlay.transform.Find("LockText");
            if (t != null)
                lockText = t.GetComponent<TMP_Text>();
        }
    }
}

StageListItemView 클래스 스테이지 리스트 UI 스테이지 카드의 UI 정보 표시

2. Lobby

using UnityEngine;

public class LobbySceneBootstrap : MonoBehaviour
{
    [Header("Block Interaction")]
    [SerializeField] private BlockInteractor blockInteractor;
    // 로비에서 사용할 블럭 상호작용, 블럭은 로비니까 즉시 부술수있도록 설정

    [Header("Disable In Lobby")]
    [SerializeField] private GameObject playerHealthUIRoot; // 플레이어 HP UI
    [SerializeField] private GameObject damageFlashRoot; // 피격 화면 표시 UI
    [SerializeField] private GameObject miningProgressRoot; // 채굴 진행도 UI
    [SerializeField] private GameObject monsterSpawnerRoot; // 몬스터 스폰

    // 로비에서는 숨김처리 할 목록들 필요가없음

    private void Awake()
    {
        ApplyLobbySettings();
    }

    private void ApplyLobbySettings()
    {
        if (blockInteractor != null)
        {
            blockInteractor.SetBreakMode(BlockInteractor.BlockBreakMode.Instant);
        }

        SetActiveIfExists(playerHealthUIRoot, false);
        SetActiveIfExists(damageFlashRoot, false);
        SetActiveIfExists(miningProgressRoot, false);
        SetActiveIfExists(monsterSpawnerRoot, false);
    }

    private static void SetActiveIfExists(GameObject target, bool active)
    {
        if (target != null)
            target.SetActive(active);
    }
}

 

 LobbySceneBootstrap로비는 인게임 메인씬과 다르게 동작할 필요가 있다 필요없는 정보 hp 피격 몬스터 스폰 등 이런것들을 꺼주고 블럭 부수는거도 한번에 부서지도록 설정하는 초기화 클래스

 

using TMPro;
using UnityEngine;


public class LobbyUIController : MonoBehaviour
{
    private enum LobbyPage
    {
        Home,
        ModeSelect,
        StageSelect,
        Matching
    }
    // 로비 처음 들어왔을 때 보이는 화면, 홈, 모드선택, 스테이지 선택, 매칭 페이지 구분

    [Header("Always Visible")]
    [SerializeField] private GameObject topStartButtonRoot;
    [SerializeField] private GameObject chatPanelRoot;
    // 시작 스타트 버튼, 채팅창  상시 활성화

    [Header("Pages")]
    [SerializeField] private GameObject modeSelectPanel; // 모드 선택 패널
    [SerializeField] private GameObject stageSelectPanel; // 스테이지 선택 패널
    [SerializeField] private GameObject matchingPanel; // 매칭 패널
    // 각 페이지

    [Header("Matching UI")]
    [SerializeField] private TMP_Text matchingTimeText;
    // 매칭 페이지

    private LobbyPage currentPage = LobbyPage.Home; // 현재 어떤 페이지 상태인지 저장
    private float matchingElapsedSeconds; // 매칭 초
    private bool isMatching; // 매칭 중 여부

    private void Start()
    {
        ShowHome(); // 시작하면 기본 페이지 홈 
    }

    private void Update()
    {
        if (!isMatching)
            return;

        matchingElapsedSeconds += Time.deltaTime; // 매칭 시간 계산 
        UpdateMatchingTimerText(); // 계산된 시간 텍스트 반영
    }

    public void OnClickStartGame()
    {
        ShowModeSelect();
    }

    public void OnClickSinglePlay()
    {
        ShowStageSelect();
    }

    public void OnClickMultiPlay()
    {
        ShowMatching();
    }

    public void OnClickInviteFriend()
    {
        Debug.Log("[LobbyUI] 친구 초대 기능은 아직 UI만 준비된 상태입니다.");
    }

    public void OnClickBackToHome()
    {
        ShowHome();
    }

    public void OnClickBackToModeSelect()
    {
        ShowModeSelect();
    }

    public void OnClickCancelMatching()
    {
        ShowModeSelect();
    }
    // 클릭하면 보여줄 페이지들
    private void ShowHome()
    {
        currentPage = LobbyPage.Home;
        isMatching = false;
        matchingElapsedSeconds = 0f;

        SetActive(topStartButtonRoot, true);
        SetActive(chatPanelRoot, true);

        SetActive(modeSelectPanel, false);
        SetActive(stageSelectPanel, false);
        SetActive(matchingPanel, false);
        // 기본 페이지 홈에서 보여줄 패널 설정
    }

    private void ShowModeSelect()
    {
        currentPage = LobbyPage.ModeSelect;
        isMatching = false;
        matchingElapsedSeconds = 0f;

        // 모드 선택 화면에서는 게임 시작 버튼은 숨긴다.
        // 채팅창은 계속 유지한다.
        SetActive(topStartButtonRoot, false);
        SetActive(chatPanelRoot, true);

        SetActive(modeSelectPanel, true);
        SetActive(stageSelectPanel, false);
        SetActive(matchingPanel, false);

        // 모드 선택 페이지
    }

    private void ShowStageSelect()
    {
        currentPage = LobbyPage.StageSelect;
        isMatching = false;
        matchingElapsedSeconds = 0f;

        SetActive(topStartButtonRoot, false);
        SetActive(chatPanelRoot, true);

        SetActive(modeSelectPanel, false);
        SetActive(stageSelectPanel, true);
        SetActive(matchingPanel, false);

        // 스테이지 선택 페이지
    }

    private void ShowMatching()
    {
        currentPage = LobbyPage.Matching;
        isMatching = true;
        matchingElapsedSeconds = 0f;

        SetActive(topStartButtonRoot, false);
        SetActive(chatPanelRoot, true);

        SetActive(modeSelectPanel, false);
        SetActive(stageSelectPanel, false);
        SetActive(matchingPanel, true);

        UpdateMatchingTimerText();

        // 매칭 페이지 
    }

    private void UpdateMatchingTimerText()
    {
        if (matchingTimeText == null)
            return;

        int totalSeconds = Mathf.FloorToInt(matchingElapsedSeconds);
        int minutes = totalSeconds / 60;
        int seconds = totalSeconds % 60;

        matchingTimeText.text = $"{minutes:00}:{seconds:00}";
        // 매칭 시간 표시
    }

    private static void SetActive(GameObject target, bool active)
    {
        if (target != null)
            target.SetActive(active);
        // 타겟 활성화, 
    }
}

 

 LobbyUIController 클래스, 로비에서의 화면 UI 정보 담당, Page에 따라 구분하여 보여줄 패널의 정보를 표시해줌

using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class VerticalStageSnap : MonoBehaviour, IBeginDragHandler, IEndDragHandler
{
    [Header("Refs")]
    [SerializeField] private ScrollRect scrollRect;
    // 스테이지 세로 스크롤 움직이기

    [Header("Snap")]
    [SerializeField] private float snapSpeed = 12f; // 스냅 카드 위치 이동 보간
    [SerializeField] private float snapStopDistance = 0.5f; // 해당 값 이하가 되면 스냅 완료

    public event Action<int> ClosestIndexChanged; // 선택된 카드가 바뀌었을 때의 이벤트
    public event Action<int> SnappedToIndex; // 스냅 카드 이동이 완료 되었을 경우의 이벤트

    private RectTransform content; 
    private RectTransform viewport;

    private bool isDragging; // 스크롤 중인지
    private bool isSnapping; // 스냅 중인지

    private int targetIndex = -1; // 현재 스냅 목표 카드
    private int lastClosestIndex = -1; // 이전과 상태가 달라졌을 경우를 위한 변수, 불필요한 갱신 막기

    private Vector2 targetAnchoredPosition; 

    private void Awake()
    {
        if (scrollRect == null)
            scrollRect = GetComponent<ScrollRect>();
        

        if (scrollRect != null)
        {
            content = scrollRect.content;
            viewport = scrollRect.viewport;
        }
        // 스크롤 렉트 찾아서 연결
    }

    private void LateUpdate()
    {
        UpdateClosestIndex();
        // 현재 선택된 카드의 위아래 가장 가까운 카드 index 갱신
        // ScrollRect가 Update 동안 Content 위치를 갱신하고 그 결과를 기준으로 계산하기 위해
        // LateUpdate 사용

        if (!isSnapping || isDragging || content == null)
            return;
        // 스냅 중이 아니면 이동 x, 다음 카드 없으면 이동 대상 없음

        content.anchoredPosition = Vector2.Lerp(
            content.anchoredPosition,
            targetAnchoredPosition,
            Time.unscaledDeltaTime * snapSpeed
            // 현재 카드의 위치를 목표 위치로 부드럽게 이동
            // 로비 UI는 게임 시간 배속이나 Time,timeScale의 영향을 덜 받는 것이 자연스럽기 때문에
            // Time.unscaledDeltaTime을 사용
        );

        if (Vector2.Distance(content.anchoredPosition, targetAnchoredPosition) <= snapStopDistance)
        {
            content.anchoredPosition = targetAnchoredPosition; 
            // 목표 카드에 충분히 가까워 졌다면 
            // 정확한 위치로 고정
            isSnapping = false; // 스냅 종료

            if (targetIndex >= 0)
                SnappedToIndex?.Invoke(targetIndex);
            // 유효한 목표 index가 있다면 스냅 완료 이벤트 발생
        }
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        isDragging = true; // 드래드 시작
        isSnapping = false; // 스냅 중단
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        isDragging = false; // 드래그 끝
        SnapToClosest(false); // 손을 뗀 순간 가장 가까운 카드를 찾아 부드럽게 스냅하여 움직여줌
    }

    public void SnapToClosest(bool immediate)
    {
        int closest = FindClosestChildIndex(); // 현재 중앙에 가장 가까운 자식 index를 찾음  
        SnapToIndex(closest, immediate); // 해당되는 카드를 스냅 이동하여 중앙으로
    }

    public void SnapToIndex(int index, bool immediate)
    {
        if (content == null || viewport == null)
            return;

        if (index < 0 || index >= content.childCount)
            return;
        // 범위 밖이면 무시

        targetIndex = index; // 스냅 목표 저장
        targetAnchoredPosition = CalculateTargetAnchoredPosition(index);
        // 목표 카드가 중앙에 오기 위한 위치 계산

        if (immediate)
        {
            content.anchoredPosition = targetAnchoredPosition; // 즉시 이동 옵션이라면 바로 목표 위치로 바꿔줌
            isSnapping = false; // 부드러운 스냅 이동 x 

            UpdateClosestIndex(force: true); // 즉시 이동 했기에 index 다시 계산하고 이벤트 종료
            SnappedToIndex?.Invoke(targetIndex); // 즉시 스냅 완료 이벤트
        }
        else
        {
            isSnapping = true; // 부드럽게 이동하도록 LateUpdate 보간
        }
    }

    private void UpdateClosestIndex(bool force = false)
    {
        int closest = FindClosestChildIndex();
        // 현재 중앙에 가장 가까운 자식 index 찾기

        if (closest < 0)
            return;
        // 찾지 못했다면 종료

        if (force || closest != lastClosestIndex)
        {
            lastClosestIndex = closest;
            // 마지막으로 알린 index 갱신
            ClosestIndexChanged?.Invoke(closest);
            // 중앙에 가까운 index가 바뀌었음을 외부로 알려줌
            // StageSelectContoller는 이 이벤트를 받아 SelectStage 호출
        }
    }

    private int FindClosestChildIndex()
    {
        if (content == null || viewport == null || content.childCount == 0)
            return -1;
        // 참조 있는지 확인

        Canvas.ForceUpdateCanvases(); // 캔버스 갱신 레이아웃을 최신상태로 유지

        Vector3 viewportCenterWorld = viewport.TransformPoint(viewport.rect.center);
        // 로컬 중앙 좌표를 월드 기준 좌표로 바꿔줌

        float bestDistance = float.MaxValue; // 현재까지 발견한 가장 가까운 거리
        int bestIndex = 0; // 가장 가까운 index

        for (int i = 0; i < content.childCount; i++)
        {
            RectTransform child = content.GetChild(i) as RectTransform;

            if (child == null || !child.gameObject.activeSelf)
                continue;
            // 비활성화인 카드 계산에서 제외

            Vector3 childCenterWorld = child.TransformPoint(child.rect.center);
            // 자식 카드의 중앙 좌표를 월드 좌표로 변환

            float distance = Mathf.Abs(viewportCenterWorld.y - childCenterWorld.y);
            // 세로 스크롤 = y축 거리 비교

            if (distance < bestDistance)
            {
                bestDistance = distance;
                bestIndex = i;
                // 더 가까운 거리를 찾으면 해당 값으로 갱신
            }
        }

        return bestIndex; // 가장 중앙에 가까운 자식 index 반환
    }

    private Vector2 CalculateTargetAnchoredPosition(int index)
    {
        Canvas.ForceUpdateCanvases(); // 레이아웃 계산 최신 상태로 맞춰줌

        RectTransform child = content.GetChild(index) as RectTransform;
        // 스냅 목표가 되는 자식 카드

        if (child == null)
            return content.anchoredPosition;
        // 자식이 없다면 현재 위치 반환

        Vector3 viewportCenterWorld = viewport.TransformPoint(viewport.rect.center);
        // Viewport의 중앙 월드 좌표
        Vector3 childCenterWorld = child.TransformPoint(child.rect.center);
        // 목표 카드 중앙의 월드 좌표

        Vector3 viewportCenterLocal = content.InverseTransformPoint(viewportCenterWorld);
        // Viewport 중앙 월드 좌표 Content 로컬 좌표계로 변환

        Vector3 childCenterLocal = content.InverseTransformPoint(childCenterWorld);
        // 카드 중앙 월드 좌표 Content 로컬 좌표계로 변환

        float diffY = viewportCenterLocal.y - childCenterLocal.y;
        // 카드 중앙이 ViewPort 중앙에 오기 위한 y축 이동 거리 계산

        Vector2 result = content.anchoredPosition;
        // 계산 결과를 기준으로 시작

        result.y += diffY;
        // 계산한 차이만큼 y 위치 보정

        return result; // 목표값 반환
    }
}

 

VerticalStageSnap 

스테이지 스크롤 창에서 드래그를 통해 스크롤 하여 스테이지 선택 시 부드럽게 자연스럽게 내가 선택한 스테이지가 가운데에 위치하도록 해주는 클래스,

 

using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;



public class StageSelectController : MonoBehaviour
{
    [Header("Stage Data")]
    [SerializeField] private List<StageDefinition> stages = new List<StageDefinition>();

    [Header("List UI")]
    [SerializeField] private Transform contentRoot; // 생성될 스테이지 카드 부모 위치
    [SerializeField] private StageListItemView itemPrefab; // 스테이지 카드 프리팹 
    [SerializeField] private VerticalStageSnap verticalSnap; // 중앙에 가장 가까운 카드 계산

    [Header("Random Map UI")] // 랜덤맵 UI 정보
    [SerializeField] private Button randomMapButton; 
    [SerializeField] private Image randomMapPreviewImage; 
    [SerializeField] private TMP_Text randomMapText; 
    [SerializeField] private int randomMapMinChunks = 5;
    [SerializeField] private int randomMapMaxChunks = 30;
    [SerializeField] private int randomSeedMin = 100000;
    [SerializeField] private int randomSeedMax = 999999;

    [Header("Info UI")] // 맵 정보 UI 
    [SerializeField] private Image mapPreviewImage;
    [SerializeField] private TMP_Text stageNameText;
    [SerializeField] private TMP_Text mapSizeText;
    [SerializeField] private TMP_Text monsterCountText;
    [SerializeField] private TMP_Text internalStageCountText;
    [SerializeField] private TMP_Text rewardText;
    [SerializeField] private TMP_Text lockStateText;

    [Header("Start")] // 게임 시작 버튼 정보
    [SerializeField] private Button startStageButton;
    [SerializeField] private TMP_Text startStageButtonText;
    [SerializeField] private string gameSceneName = "Main";

    [Header("Debug")]
    [SerializeField] private bool createDefaultStagesIfEmpty = true;
    [SerializeField] private bool logDebug = true;

    private readonly List<StageListItemView> itemViews = new List<StageListItemView>();
    // 런타임에 생성된 카드 목록

    private int selectedIndex = -1; // 현재 선택된 스테이지
    private StageDefinition selectedStage; // 일반 스테이지 데이터
    private bool selectedUnlocked; // 잠금 해제 상태 여부

    private bool isRandomMapSelected; // 현재 랜더맵 선택 여부
    private int selectedRandomChunksX; // 랜덤맵 x 청크 수
    private int selectedRandomChunksZ; // z 청크
    private int selectedRandomSeed; // 시드 

    private void Awake()
    {
        if (createDefaultStagesIfEmpty && stages.Count == 0)
            CreateDefaultStages();
        // 만들어 둔 스테이지 없으면 테스트 스테이지 만들어주기
    }

    private void Start()
    {
        BuildList(); // 스테이지 리스트 생성
        BindEvents(); // 버튼 이벤트 연결
        SelectFirstAvailableStage(); // 처음 시작 첫 번째 스테이지를 자동 선택

        if (logDebug)
        {
            Debug.Log(
                $"[StageSelectController] Start Complete | " +
                $"Stages={stages.Count}, Items={itemViews.Count}, " +
                $"ContentRoot={(contentRoot != null ? contentRoot.name : "NULL")}, " +
                $"ItemPrefab={(itemPrefab != null ? itemPrefab.name : "NULL")}"
            );
        }
    }

    private void OnEnable()
    {
        RefreshAllItems(); // 카드 상태 갱신
        RefreshInfoPanel(); // 정보 패널도 선택에 맞게 갱신
    }

    private void OnDestroy()
    {
        if (verticalSnap != null)
        {
            verticalSnap.ClosestIndexChanged -= OnCenterClosestIndexChanged;
            verticalSnap.SnappedToIndex -= OnSnappedToIndex;
        }
        // 이벤트 구독 해제
        // 오브젝트 파괴 이벤트 구독
        // null 참조 확인
    }

    private void BindEvents()
    {
        if (verticalSnap != null)
        {
            verticalSnap.ClosestIndexChanged -= OnCenterClosestIndexChanged;
            verticalSnap.SnappedToIndex -= OnSnappedToIndex;
            // 중복 구독 막기, 제거

            verticalSnap.ClosestIndexChanged += OnCenterClosestIndexChanged;
            verticalSnap.SnappedToIndex += OnSnappedToIndex;
            // 이벤트 구독
        }
        else
        {
            Debug.LogWarning("[StageSelectController] VerticalStageSnap is not assigned.");
        }

        if (startStageButton != null)
        {
            startStageButton.onClick.RemoveAllListeners(); // 기존 이벤트 제거
            startStageButton.onClick.AddListener(StartSelectedRun); 
            // Start 버튼 클릭 시 선택된 스테이지 or 랜덤 맵으로 게임 시작
        }

        if (randomMapButton != null)
        {
            randomMapButton.onClick.RemoveAllListeners(); 
            randomMapButton.onClick.AddListener(SelectRandomMap); 
        }

        if (randomMapText != null)
            randomMapText.text = "Random Map";
    }

    private void BuildList()
    {
        itemViews.Clear(); // 기존 목록 초기화

        if (contentRoot == null)
        {
            Debug.LogError("[StageSelectController] ContentRoot is not assigned.");
            return;
        }

        if (itemPrefab == null)
        {
            Debug.LogError("[StageSelectController] ItemPrefab is not assigned.");
            return;
        }

        for (int i = contentRoot.childCount - 1; i >= 0; i--)
            Destroy(contentRoot.GetChild(i).gameObject);
        // 기존 카드 자식들 제거
        // 테스트 중 생성이 호출되거나, 임시 자식이 남아 있는 경우 중복 생성 막기

        for (int i = 0; i < stages.Count; i++)
        {
            StageListItemView view = Instantiate(itemPrefab, contentRoot);
            // 스테이지 카드 프리팹 생성
            view.gameObject.SetActive(true);
            // 프리팹 비활성 상태여도 생성 후 켜주기
            view.Initialize(this, i, stages[i]);
            // 카드 초기화
            itemViews.Add(view);
            // 생성한 View 목록 저장
        }

        Canvas.ForceUpdateCanvases();
        // 생성 직후 UI 레이아웃이 아직 계산되지 않았을 수 있으므로 강제 갱신

        if (logDebug)
            Debug.Log($"[StageSelectController] BuildList Complete | Item Count={itemViews.Count}");
    }

    public void OnClickStageItem(int index)
    {
        isRandomMapSelected = false;
        // 일반맵 선택 시 랜덤 맵 선택 상태 해제
        SelectStage(index); // 클릭한 카드의 스테이지를 현재 선택 상태로 만들어줌

        if (verticalSnap != null)
            verticalSnap.SnapToIndex(index, false);
        // 선택된 카드 중앙으로 부드럽게 스냅 이동 해줌
    }

    private void OnCenterClosestIndexChanged(int index)
    {
        isRandomMapSelected = false;
        SelectStage(index);
        // 중앙에 가장 가까운 카드 index를 현재 스테이지로 
    }

    private void OnSnappedToIndex(int index)
    {
        isRandomMapSelected = false;
        SelectStage(index);
        // 스냅이 끝나면 목표된 카드를 최종 선택 스테이지로 
    }

    private void SelectStage(int index)
    {
        if (index < 0 || index >= stages.Count)
            return;
        // 유효하지 않은 index 

        selectedIndex = index; // 현재 선택된 카드 index 저장
        selectedStage = stages[index]; // 스테이지 설명 저장
        selectedUnlocked = StageProgressStore.IsStageUnlocked(selectedStage); // 잠금 상태 확인

        RefreshAllItems(); // 카드들의 선택 표시와 잠금 표시 갱신
        RefreshInfoPanel(); // 정보창 갱신
    }

    private void SelectFirstAvailableStage()
    {
        if (stages.Count == 0)
        {
            selectedIndex = -1;
            selectedStage = null;
            selectedUnlocked = false;
            RefreshInfoPanel();
            return;
        }
        // 스테이지 데이터가 없다면 선택 비우기

        int firstIndex = 0;
        // 모두 잠겨 있다면 0번 임시 스테이지 

        for (int i = 0; i < stages.Count; i++)
        {
            if (StageProgressStore.IsStageUnlocked(stages[i]))
            {
                firstIndex = i;
                break;
            }
        }
        // 잠금 해제된 첫 번째 스테이지 찾기

        isRandomMapSelected = false;
        SelectStage(firstIndex); // 찾은 스테이지 선택

        if (verticalSnap != null)
            verticalSnap.SnapToIndex(firstIndex, true);
        // 최초 선택 즉시 중앙으로 이동, 보간 이동 없이 바로 이동
        //
    }

    private void SelectRandomMap()
    {
        isRandomMapSelected = true;
        // 현재 선택을 랜덤 맵으로 변경

        int randomSize = Random.Range(randomMapMinChunks, randomMapMaxChunks + 1);
        // 랜덤맵 크기 결정

        selectedRandomChunksX = randomSize;
        selectedRandomChunksZ = randomSize;
        selectedRandomSeed = Random.Range(randomSeedMin, randomSeedMax + 1);
        // 사이즈에 따른 정사각형 맵 생성 및 랜덤 시드 결정 

        selectedIndex = -1; // 일반 스테이지 선택 해제
        selectedStage = null; // 선택 스테이지 설명 해제
        selectedUnlocked = true; // 랜덤 맵은 항상 시작하도록 true 설정

        RefreshAllItems(); // 일반 스테이지 카드 정보 표시 해제
        RefreshInfoPanel(); // 랜덤맵 정보 표시

        Debug.Log(
            $"[StageSelectController] Random Map Selected | " +
            $"MapSize={selectedRandomChunksX}x{selectedRandomChunksZ}, Seed={selectedRandomSeed}"
        );
    }

    private void RefreshAllItems()
    {
        for (int i = 0; i < itemViews.Count; i++)
        {
            if (itemViews[i] == null)
                continue;

            bool unlocked = StageProgressStore.IsStageUnlocked(stages[i]);
            // i번 째 스테이지 잠금 해제 여부
            bool selected = !isRandomMapSelected && i == selectedIndex;
            // 랜덤 맵이 선택된 상태가 아니고, i가 현재 선택된 카드와 같다면 현재 선택된 카드임을 알려줌

            itemViews[i].Refresh(unlocked, selected);
            // 카드 UI 상태 갱신, 정보 처리 
        }
    }

    private void RefreshInfoPanel()
    {
        if (isRandomMapSelected)
        {
            RefreshRandomMapInfoPanel();
            return;
        }
        // 랜덤맵 선택이면 랜덤맵 정보로 

        RefreshFixedStageInfoPanel(); 
        // 위가 아니라면 일반 스테이지 정보 표시
    }

    private void RefreshFixedStageInfoPanel()
    {
        if (selectedStage == null)
        {
            SetText(stageNameText, "Stage: -");
            SetText(mapSizeText, "Map Size: -");
            SetText(monsterCountText, "Monsters: -");
            SetText(internalStageCountText, "Internal Stages: -");
            SetText(rewardText, "Reward: -");
            SetText(lockStateText, "");
            SetStartButton(false, "Start");
            return;
        }
        // 선택된 스테이지 없으면 빈 정보로 표시

        if (mapPreviewImage != null)
        {
            mapPreviewImage.sprite = selectedStage.previewSprite;
            // 선택된 스테이지의 미리보기 이미지 적용
            mapPreviewImage.enabled = selectedStage.previewSprite != null;
            // 이미지가 없으면 Preview Image를 숨겨줌
        }

        // 스테이지 정보 표시 텍스트
        SetText(stageNameText, $"Stage {selectedStage.stageId} - {selectedStage.stageName}");
        // 스테이지 번호 이름
        SetText(mapSizeText, $"Map Size: {selectedStage.MapSizeLabel}");
        // 맵 크기
        SetText(monsterCountText, $"Monsters: {selectedStage.monsterCount}");
        // 몬스터 수
        SetText(internalStageCountText, $"Internal Stages: {selectedStage.internalStageCount}");
        // 내부 스테이지 수
        SetText(rewardText, $"Reward: {selectedStage.rewardText}");
        // 주요 보상

        if (selectedUnlocked)
        {
            bool cleared = StageProgressStore.IsStageCleared(selectedStage);
            // 이미 클리어한 스테이지인지 확인
            SetText(lockStateText, cleared ? "Cleared" : "Available");
            // 클리어 했다면 Cleared, 열려있는 상태면(클리어 x), Available 표시 
            SetStartButton(true, "Start");
            // 시작 가능한 스테이지 Start 버튼 활성화
        }
        else
        {
            SetText(lockStateText, $"Clear Stage {selectedStage.requiredClearedStageId}");
            // 잠긴 스테이지면 어떤 스테이지를 클리어해야 해금 되는지 표시
            SetStartButton(false, "Locked"); // 락상태, 스타트 버튼 비활성화
        }
    }

    private void RefreshRandomMapInfoPanel()
    {
        if (mapPreviewImage != null)
        {
            mapPreviewImage.sprite = randomMapPreviewImage != null ? randomMapPreviewImage.sprite : null;
            mapPreviewImage.enabled = mapPreviewImage.sprite != null;
        }

        // 랜덤맵 정보 표시 텍스트
        SetText(stageNameText, "Random Map");
        SetText(mapSizeText, $"Map Size: {selectedRandomChunksX}x{selectedRandomChunksZ}");
        SetText(monsterCountText, "Monsters: n x 2");
        SetText(internalStageCountText, "Internal Stages: Infinite");
        SetText(rewardText, "Reward: Random");
        SetText(lockStateText, "Available");
        SetStartButton(true, "Start");
    }

    private void SetStartButton(bool interactable, string text)
    {
        if (startStageButton != null)
            startStageButton.interactable = interactable;
        // 스타트 버튼 활성/비활
        
        if (startStageButtonText != null)
            startStageButtonText.text = text;
        // 스타트 버튼 텍스트 변경
    }

    private static void SetText(TMP_Text target, string text)
    {
        if (target != null)
            target.text = text;
        // Inspector 연결 누락으로 널 참조 방지
    }

    private void StartSelectedRun()
    {
        if (isRandomMapSelected)
        {
            StageSelectionStore.SelectRandomMap(
                selectedRandomChunksX,
                selectedRandomChunksZ,
                selectedRandomSeed
            );
            // 랜덤 맵 정보를 StageSelectStore에 저장
            // 메인씬(인게임)에서 GameStageBootstrap이 해당 값을 읽어
            // VoxelWorld에 청크, 시드 적용
            Debug.Log(
                $"[StageSelectController] Start Random Map | " +
                $"MapSize={StageSelectionStore.MapSizeLabel}, Seed={StageSelectionStore.MapSeed}"
            );

            SceneManager.LoadScene(gameSceneName); // 메인씬 인게임으로 이동
            return;
        }

        if (selectedStage == null)
        {
            Debug.LogWarning("[StageSelectController] No selected stage.");
            return;
        }
        // 선택된 일반 스테이지가 없다면 시작 x

        if (!selectedUnlocked)
        {
            Debug.Log("[StageSelectController] Selected stage is locked.");
            return;
        }
        // 잠긴 스테이지면 시작 x 

        StageSelectionStore.SelectStage(selectedStage);
        // 선택된 스테이지 정보 저장

        Debug.Log(
            $"[StageSelectController] Start Stage | " +
            $"StageId={selectedStage.stageId}, " +
            $"Name={selectedStage.stageName}, " +
            $"Chunks={selectedStage.chunksX}x{selectedStage.chunksZ}, " +
            $"Monsters={selectedStage.monsterCount}, " +
            $"InternalStages={selectedStage.internalStageCount}, " +
            $"Seed={selectedStage.mapSeed}"
        );

        SceneManager.LoadScene(gameSceneName); // 메인 게임씬으로 이동
    }

    [ContextMenu("Debug Clear Stage Progress")]
    private void DebugClearStageProgress()
    {
        StageProgressStore.ResetProgress();
        RefreshAllItems();
        RefreshInfoPanel();
        // 저장된 클리어 진행도를 초기화하고 진행도 바뀜에 따른 UI 갱신
    }

    [ContextMenu("Debug Clear Stage 1")]
    private void DebugClearStageOne()
    {
        StageProgressStore.MarkStageCleared(1);
        RefreshAllItems();
        RefreshInfoPanel();
        // 스테이지 1은 클리어 한걸로 저장, 진행도 UI 갱신 
    }
    // 위는 동작 확인 디버깅 위한 테스트 
    
    private void CreateDefaultStages()
    {
        stages.Clear(); // 스테이지 리스트 비우기
        // 스테이지 정보 추가 

        stages.Add(new StageDefinition
        {
            stageId = 1,
            stageName = "Grassland Defense",
            requiredClearedStageId = 0,
            chunksX = 5,
            chunksZ = 5,
            mapSeed = 1001,
            monsterCount = 10,
            internalStageCount = 5,
            rewardText = "Stone",
            difficulty = 1
        });

        stages.Add(new StageDefinition
        {
            stageId = 2,
            stageName = "Hill Defense",
            requiredClearedStageId = 1,
            chunksX = 6,
            chunksZ = 6,
            mapSeed = 2001,
            monsterCount = 15,
            internalStageCount = 5,
            rewardText = "Coal",
            difficulty = 2
        });

        stages.Add(new StageDefinition
        {
            stageId = 3,
            stageName = "Mine Defense",
            requiredClearedStageId = 2,
            chunksX = 7,
            chunksZ = 7,
            mapSeed = 3001,
            monsterCount = 20,
            internalStageCount = 5,
            rewardText = "Gold",
            difficulty = 3
        });
    }
}

 

StageSelectController 

카드 클릭 시의 흐름 인게임 청크, 시드 반영 및 스테이지 정보 표시 및 반영

 

using UnityEngine;


[DefaultExecutionOrder(-1000)]
public class GameStageBootstrap : MonoBehaviour
{
    [Header("Refs")]
    [SerializeField] private VoxelWorld voxelWorld;
    // 메인씬 인게임에 존재하는 VoxelWorld 참조
    // 스테이지 선택에 따른 정보를 실제 맵에 적용하기

    private void Awake()
    {
        if (voxelWorld == null)
            voxelWorld = FindObjectOfType<VoxelWorld>();
        // 복셀월드 연결해주기
        ApplySelectedStage();
        // 메인씬이 시작될 때 선택된 스테이지 정보를 실제 게임씬에 적용하기
        // Awake에서 해주어야 복셀월드에서 Start로 맵 생성 전에 값을 넣어줄 수 있음
    }

    private void ApplySelectedStage()
    {
        if (!StageSelectionStore.HasSelection)
        {
            Debug.Log("[GameStageBootstrap] No selected stage. Using VoxelWorld Inspector settings.");
            return;
        }
        // 테스트에서 직접 게임씬으로 들어오는 경우 기본값 사용, 방어 코드

        if (voxelWorld == null)
        {
            Debug.LogWarning("[GameStageBootstrap] VoxelWorld not found.");
            return;
        }
        // 복셀 월드 참조 실패 시 방어

        voxelWorld.ConfigureStageWorld(
            StageSelectionStore.ChunksX,
            StageSelectionStore.ChunksZ,
            StageSelectionStore.MapSeed
        );
        // 복셀 월드에 적용할 청크와 맵시드값

        Debug.Log(
            $"[GameStageBootstrap] Apply Stage | " +
            $"Mode={StageSelectionStore.Mode}, " +
            $"Stage={StageSelectionStore.SelectedStageName}, " +
            $"MapSize={StageSelectionStore.MapSizeLabel}, " +
            $"Seed={StageSelectionStore.MapSeed}, " +
            $"Monsters={StageSelectionStore.MonsterCountPerInternalStage}, " +
            $"InternalStages={StageSelectionStore.InternalStageCount}, " +
            $"Difficulty={StageSelectionStore.Difficulty}"
        );
    }
}

 

GameBootstrap

로비씬과 메인씬 사이의 연결로 선택된 스테이지 정보에 따른 청크, 맵시드 값을 실제 복셀월드에 전달해줌

 

VoxelWorld에 추가할 함수

처음 Stage Select 화면의 정보가 UI에서만 바뀌고 실제 맵 크기는 바뀌지 않던 오류가 있었는데

원인이 복셀월드에서 자신의 기본값으로 먼저 월드를 생성하고 있었기 때문

그렇기에 GameStageBootstrap이 StageSelectionStore를 읽어 정보를 받아 VoxelWorld.GenerateInitialWorld()가 실행되기 전에 ConfigureStageWorld()를 호출하도록 만들어줌  

 

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


public class StageWaveDirector : MonoBehaviour
{
    [Header("Refs")]
    [SerializeField] private VoxelWorld world; // 몬스터 스폰 위치 계산
    [SerializeField] private Transform player; // 플레이어 위치
    [SerializeField] private CharacterStats playerStats; // 플레이어 스탯
    [SerializeField] private GameObject monsterPrefab; // 몬스터 프리팹
    [SerializeField] private Transform monsterParent; // 몬스터 부모
    [SerializeField] private InGameHUDController hud; // 인게임 HUD 컨트롤, Stage, 진행률, 라운드 등 UI정보
    

    [Header("Timing")]
    [SerializeField] private bool autoStart = true; // true이면 Start에서 자동으로 run
    [SerializeField] private float startDelay = 1f; // 시작 딜레이 
    [SerializeField] private float nextStageCountdownSeconds = 10f; 
    // 내부 스테이지 하나 클리어 이후 다음 스테이지 시간
    [SerializeField] private float spawnInterval = 0.25f; // 스폰 시간 간격
    [SerializeField] private float aliveCheckInterval = 0.25f; 
    // 몬스터가 많은 경우 확인 시간  

    [Header("Spawn Limit")]
    [SerializeField] private int maxAliveMonstersAtOnce = 20; 
    // 맵 가장자리에서 스폰 허용 칸

    [Header("Spawn Position")] // 몬스터 스폰 위치
    [SerializeField] private int minInsetFromEdge = 1; 
    [SerializeField] private int maxInsetFromEdge = 5;
    [SerializeField] private float minDistanceFromPlayer = 8f;
    [SerializeField] private int maxSpawnAttemptsPerMonster = 80;
    [SerializeField] private float spawnYOffset = 0.05f;

    [Header("Debug")]
    [SerializeField] private bool logDebug = true;

    private readonly List<GameObject> aliveMonsters = new List<GameObject>();
    // 현재 살아있는 몬스터 목록

    private int currentInternalStage; // 현재 진행 스테이지 번호
    private int totalToSpawnThisWave; // 내부 스테이지에서 스폰해야 하는 몬스터 수
    private int spawnedThisWave; // 현재 스폰한 몬스터 수
    private int defeatedThisWave; // 처치된 몬스터

    private bool isRunning; // 현재 진행 중인지
    private bool runEnded; // 현재 스테이지 끝났는지
    private Coroutine runRoutine; // 스테이지 진행 코루틴

    private void Awake()
    {
        AutoFindRefs();
        // 필요한 참조 찾기
    }

    private void Start()
    {
        if (autoStart)
            StartRun();
    }

    private void AutoFindRefs()
    {
        // 참조 찾기
        if (world == null)
            world = FindObjectOfType<VoxelWorld>();

        if (hud == null)
            hud = FindObjectOfType<InGameHUDController>();

        if (player == null)
        {
            GameObject playerObject = GameObject.FindGameObjectWithTag("Player");

            if (playerObject != null)
                player = playerObject.transform;
        }

        if (playerStats == null && player != null)
            playerStats = player.GetComponent<CharacterStats>();
    }

    public void StartRun()
    {
        if (isRunning)
            return;

        if (runRoutine != null)
            StopCoroutine(runRoutine);
        // 기존 코루틴이 남아있으면 정리

        runRoutine = StartCoroutine(RunStageRoutine());
        // 스테이지 진행 코루틴 시작
    }

    private IEnumerator RunStageRoutine()
    {
        isRunning = true;
        runEnded = false;
        currentInternalStage = 0;
        // 스테이지 진행 상태 초기화

        AutoFindRefs(); // 참조 확인

        if (!StageSelectionStore.HasSelection)
        {
            Debug.LogWarning("[StageWaveDirector] No selected stage. Wave will not start.");
            isRunning = false;
            yield break;
        }
        // 선택 스테이지 정보 확인

        yield return WaitUntilWorldReady(); // 복셀월드 표면 생성 기다리기
        // 몬스터 먼저 생성되는거 방지,  몬스터가 생성되는데 안보였던 오류 해결

        if (monsterPrefab == null)
        {
            Debug.LogWarning("[StageWaveDirector] Monster Prefab is not assigned.");
            isRunning = false;
            yield break;
        }
        // 몬스터 프리팹 확인

        int firstStageMonsterCount = StageSelectionStore.GetMonsterCountForInternalStage(1);
        // 첫 번째 내부 스테이지에서 등장할 몬스터 수 계산

        if (hud != null)
        {
            hud.ResetPlayTimer(); // 플레이 시간 초기화
            hud.StartPlayTimer(); // 플레이 시간 측정 시작

            hud.SetCurrentStage(
                1,
                StageSelectionStore.InternalStageCount,
                StageSelectionStore.IsInfiniteInternalStage
            );
            // HUD에 현재 Stage 정보 표시

            hud.SetWaveProgress(0, firstStageMonsterCount);
            // 진행률 표시
        }

        if (hud != null)
            yield return hud.WaitForStartRoundInput();
        // 첫 라운드는 바로 시작하지 않고 버튼 입력 후 시작
        // 바로 시작하니 내가 생각했던 플레이 방식이 나오기힘듬
        // 벽쌓고 아이템만들어서 막는 구조였기 때문 준비 시간 필요

        yield return new WaitForSeconds(startDelay);

        if (IsPlayerDead())
        {
            EndRun(false);
            yield break;
        }
        // 시작 전에 플레이어 죽었는지 확인

        if (hud != null)
            yield return hud.PlayCenterBanner("START");
        // 중앙 스타트 ui 표시

        while (HasNextInternalStage())
        {
            if (IsPlayerDead())
            {
                EndRun(false);
                yield break;
            }
            // 내부 스테이지 시작 전 플에이어 사망 확인

            currentInternalStage++; // 내부 스테이지 번호 증가

            bool bossStage = IsBossStage(currentInternalStage);
            // 마지막 내부 스테이지 확인, 보스 스테이지 

            totalToSpawnThisWave = StageSelectionStore.GetMonsterCountForInternalStage(currentInternalStage);
            spawnedThisWave = 0;
            defeatedThisWave = 0;
            aliveMonsters.Clear();
            // 현재 내부 스테이지에서 등장할 몬스터 수 결정
            if (hud != null)
            {
                hud.SetCurrentStage(
                    currentInternalStage,
                    StageSelectionStore.InternalStageCount,
                    StageSelectionStore.IsInfiniteInternalStage
                );
                // HUD의 현재 Stage 정보 갱신

                hud.SetWaveProgress(defeatedThisWave, totalToSpawnThisWave);
                // 진행률 초기화

                if (bossStage)
                    yield return hud.PlayCenterBanner("Boss Stage");
                else
                    yield return hud.PlayCenterBanner($"Stage {currentInternalStage}");
                // 현재 내부 스테이지 시작 배너 표시
            }

            if (logDebug)
            {
                Debug.Log(
                    $"[StageWaveDirector] Internal Stage Start | " +
                    $"Mode={StageSelectionStore.Mode}, " +
                    $"InternalStage={currentInternalStage}, " +
                    $"MonsterTotal={totalToSpawnThisWave}, " +
                    $"BossStage={bossStage}, " +
                    $"MapSize={StageSelectionStore.MapSizeLabel}"
                );
            }

            yield return RunCurrentWave(); // 현재 내부 스테이지의 몬스터 스폰과 처치 대기 진행

            if (runEnded)
                yield break;
            // 내부에서 실패나 종료가 발생했다면 중단

            if (logDebug)
                Debug.Log($"[StageWaveDirector] Internal Stage Clear | {currentInternalStage}");

            if (!HasNextInternalStage())
                break;
            // 다음 내부 스테이지가 없다면 전체 클리어
      
            if (hud != null)
                yield return hud.PlayCountdown(nextStageCountdownSeconds);
            else
                yield return new WaitForSeconds(nextStageCountdownSeconds);
            // 첫 라운드 이후에는 자동 카운트다운 후 다음 라운드 시작.

        }

        EndRun(true);
        // 모든 내부 스테이지 클리어했다면 클리어 처리
    }

    private IEnumerator RunCurrentWave()
    {
        while (defeatedThisWave < totalToSpawnThisWave)
        {
            if (IsPlayerDead())
            {
                EndRun(false);
                yield break;
            }
            // 웨이브 진행 중 플레이어 사망 시 실패 처리

            CleanupDefeatedMonsters(); 
            UpdateWaveProgressUI();
            // 몬스터 죽이면 목록 제거및 파괴처리, 진행률 표시

            bool canSpawnMore = spawnedThisWave < totalToSpawnThisWave;
            // 아직 스폰한 몬스터가 있는지 확인
            bool underAliveLimit = aliveMonsters.Count < maxAliveMonstersAtOnce;
            // 현재 살아있는 몬스터 수가 제한 수 보다 적은지 확인

            if (canSpawnMore && underAliveLimit)
            {
                bool spawned = SpawnOneMonster(); // 몬스터 한 마리 스폰 시도

                if (spawned)
                {
                    spawnedThisWave++; // 스폰 성공 시 스폰 수 증가
                }
                else
                {
                    spawnedThisWave++; 
                    defeatedThisWave++;
                    // 스폰 위치 찾지 못한 경우
                    // 무한 대기에 빠지지 않도록 몬스터 스킵 처리

                    Debug.LogWarning(
                        $"[StageWaveDirector] Spawn skipped. " +
                        $"Progress={defeatedThisWave}/{totalToSpawnThisWave}"
                    );
                }

                UpdateWaveProgressUI();
                // 스폰/스킨 후 진행률 갱신

                yield return new WaitForSeconds(spawnInterval); // 다음 스폰까지 대기
            }
            else
            {
                yield return new WaitForSeconds(aliveCheckInterval);
                // 더 스폰할 수 없거나 살아있는 몬스터가 많다면 잠시 대기
            }
        }

        CleanupDefeatedMonsters();
        UpdateWaveProgressUI();
        // 웨이브 종료 직전 한번 더 정리하고 UI 갱신
    }

    private void EndRun(bool cleared)
    {
        if (runEnded)
            return;
        // 이미 종료처리 됐다면 중복 실행 방지

        runEnded = true;
        isRunning = false;
        // 종료 상태로 변경

        CleanupAllAliveMonsterReferences();
        // 살아있는 몬스터 목록 참조 정리

        if (cleared)
        {
            if (StageSelectionStore.Mode == StageSelectionStore.RunMode.FixedStage)
            {
                StageProgressStore.MarkStageCleared(StageSelectionStore.SelectedStageId);
                // 일반 스테이지 클리어 했다면 진행도 저장
               

                Debug.Log(
                    $"[StageWaveDirector] Stage Cleared. Progress saved. " +
                    $"ClearedStageId={StageSelectionStore.SelectedStageId}"
                );
            }

            if (hud != null)
                hud.ShowResult(true, StageSelectionStore.SelectedStageName);
            // 클리어 UI 표시

            Debug.Log("[StageWaveDirector] Stage Run Clear");
        }
        else
        {
            if (hud != null)
                hud.ShowResult(false, StageSelectionStore.SelectedStageName);
            // 실패 UI 표시
            Debug.Log("[StageWaveDirector] Stage Run Failed");
        }
    }

    private bool HasNextInternalStage()
    {
        if (!StageSelectionStore.HasSelection)
            return false;
        // 선택 정보가 없다면 진행 불가

        if (StageSelectionStore.IsInfiniteInternalStage)
            return true;
        // 랜덤맵이면 내부 스테이지 무한 true로

        return currentInternalStage < StageSelectionStore.InternalStageCount;
        // 현재 내부 스테이지 번호가 전체 개수보다 작을 경우 다음 스테이지 존재
    }

    private bool IsBossStage(int internalStage)
    {
        if (StageSelectionStore.IsInfiniteInternalStage)
            return false;
        // 랜덤맵은 보스 표시 x ( 고민중, 있는게 나을듯한데)

        return internalStage == StageSelectionStore.InternalStageCount;
        // 마지막 내부 스테이지 보스 스테이지로 
    }

    private bool IsPlayerDead()
    {
        if (playerStats == null)
            return false;

        return playerStats.IsDead;
        // 플레이어 죽었는지 확인
    }

    private IEnumerator WaitUntilWorldReady()
    {
        AutoFindRefs();
        // 복셀월드 참조 확인
        while (world == null)
        {
            AutoFindRefs();
            yield return null;
        }
        // 월드 찾기 대기

        yield return null;
        // 한 프레임 대기 
        // 표면 셀 찾으면 월드 생성 전일 수 있기 때문 생성되고 찾도록 대기 

        float timeout = 5f;
        float elapsed = 0f;
        // 표면 정보 기다리기 5초

        while (elapsed < timeout)
        {
            int centerX = world.WorldSizeX / 2;
            int centerZ = world.WorldSizeZ / 2;
            // 월드 중앙 좌표 계산

            if (world.TryGetSurfaceCell(centerX, centerZ, out _))
                yield break;
            // 중앙 표면 셀을 찾을 수 있다면 월드 생성 완료 판단

            elapsed += Time.deltaTime;
            yield return null;
        }

        Debug.LogWarning("[StageWaveDirector] World surface was not found. Starting anyway.");
    }

    private bool SpawnOneMonster()
    {
        if (world == null || monsterPrefab == null)
            return false;

        if (!TryGetSpawnCell(out VoxelWorld.SurfaceCell cell))
        {
            Debug.LogWarning("[StageWaveDirector] Failed to find monster spawn position.");
            return false;
        }
        // 유요한 스폰 위치 찾지 못하면 false

        Vector3 spawnPosition = cell.FootWorldPosition + Vector3.up * spawnYOffset;
        // 표면 셀 위치값에서 살짝 위로 보정해서 스폰해 몬스터가 땅에 박히지 않도록 스폰

        Quaternion rotation = Quaternion.identity; // 기본 회전값

        if (player != null)
        {
            Vector3 dir = player.position - spawnPosition;
            dir.y = 0f;
            // 플레이어 방향을 수평 방향으로 계산

            if (dir.sqrMagnitude > 0.001f)
                rotation = Quaternion.LookRotation(dir.normalized, Vector3.up);
            // 몬스터가 생성될 떄 플레이어를 바라보도록 회전
        }

        GameObject monster = Instantiate(monsterPrefab, spawnPosition, rotation, monsterParent);
        // 몬스터 프리팹 생성

        MonsterController controller = monster.GetComponent<MonsterController>();
        // 생성된 몬스터의 컨트롤러 참조

        if (controller != null)
            controller.Initialize(world, player, playerStats);
        // 몬스터에게 월드, 플레이어 , 스탯 정보 넘기기 

        aliveMonsters.Add(monster); // 살아있는 몬스터 리스트 추가

        return true;
    }

    private bool TryGetSpawnCell(out VoxelWorld.SurfaceCell cell)
    {
        cell = default;

        for (int i = 0; i < maxSpawnAttemptsPerMonster; i++)
        {
            Vector2Int xz = GetRandomEdgeXZ();
            // 맵 가장자리 근처의 랜덤 xz 좌표 선택

            if (!world.TryGetSurfaceCell(xz.x, xz.y, out cell))
                continue;
            // 해당 위치의 표면셀을 못찾았다면 다시 시도

            if (player != null)
            {
                Vector3 playerFlat = player.position;
                playerFlat.y = 0f;

                Vector3 spawnFlat = cell.FootWorldPosition;
                spawnFlat.y = 0f;

                float dist = Vector3.Distance(playerFlat, spawnFlat);
                // 플레이어와 스폰 위치 사이의 수평 거리 계산


                if (dist < minDistanceFromPlayer)
                    continue;
                // 너무 가까우면 다시 찾기
            }

            return true;
        }

        return false;
    }

    private Vector2Int GetRandomEdgeXZ()
    {
        int maxX = Mathf.Max(1, world.WorldSizeX - 1);
        int maxZ = Mathf.Max(1, world.WorldSizeZ - 1);
        // 월드 xz 최대 좌표 계산
        int safeMaxInsetX = Mathf.Clamp(maxInsetFromEdge, minInsetFromEdge, Mathf.Max(minInsetFromEdge, maxX / 2));
        int safeMaxInsetZ = Mathf.Clamp(maxInsetFromEdge, minInsetFromEdge, Mathf.Max(minInsetFromEdge, maxZ / 2));
        // 맵이 작은 경우 inset 값이 월드 크기로 넘어가지 않도록 보정
        int insetX = Random.Range(minInsetFromEdge, safeMaxInsetX + 1);
        int insetZ = Random.Range(minInsetFromEdge, safeMaxInsetZ + 1);
        // 가장자리에서 어느 정도 안으로 들어올지 랜덤 결정
        int side = Random.Range(0, 4);
        // 어느 면에서 스폰할지 경정
        switch (side)
        {
            case 0:
                return new Vector2Int(Random.Range(0, maxX + 1), insetZ);
                // 아래
            case 1:
                return new Vector2Int(Random.Range(0, maxX + 1), maxZ - insetZ);
                // 위
            case 2:
                return new Vector2Int(insetX, Random.Range(0, maxZ + 1));
                // 왼쪽
            default:
                return new Vector2Int(maxX - insetX, Random.Range(0, maxZ + 1));
                // 오른쪽
        }
    }

    private void CleanupDefeatedMonsters()
    {
        for (int i = aliveMonsters.Count - 1; i >= 0; i--)
        {
            GameObject monster = aliveMonsters[i];

            if (monster == null)
            {
                aliveMonsters.RemoveAt(i);
                defeatedThisWave++;
                continue;
            }
            // 몬스터 객체가 파괴되면 처치된걸로 판단

            CharacterStats stats = monster.GetComponent<CharacterStats>();

            if (stats != null && stats.IsDead)
            {
                aliveMonsters.RemoveAt(i);
                defeatedThisWave++;
            }
            // 캐릭터 스탯이 있고 죽어있다면 처치된것으로 판단
        }

        defeatedThisWave = Mathf.Clamp(defeatedThisWave, 0, totalToSpawnThisWave);
        // 처치 수가 총 몬스터를 넘지 않게 보정
    }

    private void CleanupAllAliveMonsterReferences()
    {
        aliveMonsters.Clear(); // 종료하면 살아있는 몬스터 목록 비우기
    }

    private void UpdateWaveProgressUI()
    {
        if (hud == null)
            return;

        hud.SetWaveProgress(defeatedThisWave, totalToSpawnThisWave);
        // 현재 진행률 표시
    }
}

 

StageWaveDirector

정보값을 받아 맵의 크기를 기반으로 월드를 생성하고 몬스터를 실제로 어디에 스폰시킬지 랜덤 스폰을 시키고

웨이브를 시작한다.

 

using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class InGameHUDController : MonoBehaviour
{
    [Header("Top Info")] // 화면 상단에 표시할 정보, 현재 스테이지, 웨이브, 플레이타임
    [SerializeField] private TMP_Text currentStageText;
    [SerializeField] private TMP_Text waveProgressText;
    [SerializeField] private TMP_Text playTimeText;

    [Header("Center Banner")] // 화면 중단에 나올 Ui
    [SerializeField] private RectTransform centerBannerRoot; // 중앙에 보여주고 없에기
    [SerializeField] private TMP_Text centerBannerText; // 중앙 배너에 표시할 텍스트
    [SerializeField] private CanvasGroup centerBannerCanvasGroup; // 배너 투명도

    [Header("Countdown")] 
    [SerializeField] private GameObject countdownPanel; // 라운드 시작 전 카운트 다운 
    [SerializeField] private Image countdownRingFill; // 원형 카운트 다운 게이지
    [SerializeField] private TMP_Text countdownNumberText; // 카운트 다운 숫자 텍스트

    [Header("Start Round")]
    [SerializeField] private GameObject startRoundPanel;  // 시작 UI
    [SerializeField] private TMP_Text startRoundHelpText; // 시작 안내 문구
    [SerializeField] private Button startRoundButton; // 라운드 시작 버튼
    [SerializeField] private TMP_Text startRoundButtonText; // 라운드 시작 버튼 텍스트

    [Header("Result Panel")] // 결과창
    [SerializeField] private GameObject resultPanel;
    [SerializeField] private TMP_Text resultTitleText; // 제목
    [SerializeField] private TMP_Text resultStageText; // 스테이지 이름
    [SerializeField] private TMP_Text resultTimeText; // 플레이 시간
    [SerializeField] private Button retryButton; // 다시 시작 버튼
    [SerializeField] private Button lobbyButton; // 로비 돌아가기 버튼

    [Header("Scene Names")]
    [SerializeField] private string lobbySceneName = "LobbyScene";
    [SerializeField] private string gameSceneName = "Main";

    [Header("Banner Animation")]
    [SerializeField] private float bannerMoveDistance = 120f; 
    [SerializeField] private float bannerInDuration = 0.25f;
    [SerializeField] private float bannerStayDuration = 0.75f;
    [SerializeField] private float bannerOutDuration = 0.25f;
    // 중앙에 띄워줄 UI는 아래에서 중앙으로 들어와 위로 나감
    // 그러기 위한 움직일 거리, 이동 시간, 머무르는 시간, 사라지는데에 걸리는 시간 설정 

    public float TotalPlayTime => totalPlayTime; // 총 플레이 시간

    private float totalPlayTime; // 플레이 시간 누적값
    private bool timerRunning; // 플레이 시간 타이머 동작 중인지

    private Vector2 bannerCenterPos; // 중앙 UI의 원래 위치

    private bool startRoundRequested; // 라운드 시작 버튼이 눌러졌는지 여부

    private void Awake()
    {
        if (centerBannerRoot != null)
        {
            bannerCenterPos = centerBannerRoot.anchoredPosition;
            // 중앙 배너의 기본 위치 저장
            // 시작 위치, 종료 위치 계산 기준점
            if (centerBannerCanvasGroup == null)
                centerBannerCanvasGroup = centerBannerRoot.GetComponent<CanvasGroup>();

            if (centerBannerCanvasGroup == null)
                centerBannerCanvasGroup = centerBannerRoot.gameObject.AddComponent<CanvasGroup>();
            // Inspector 참조값 찾기

            centerBannerRoot.gameObject.SetActive(false);
            // 시작 시 중앙 배너를 숨겨줌, 라운드 시작 버튼을 눌렀을 경우에 나오도록
        }

        if (countdownPanel != null)
            countdownPanel.SetActive(false);

        if (resultPanel != null)
            resultPanel.SetActive(false);

        if (startRoundPanel != null)
            startRoundPanel.SetActive(false);

        if (startRoundButton != null)
        {
            startRoundButton.onClick.RemoveAllListeners();
            startRoundButton.onClick.AddListener(OnClickStartRound);
        }

        if (retryButton != null)
        {
            retryButton.onClick.RemoveAllListeners();
            retryButton.onClick.AddListener(OnClickRetry);
        }

        if (lobbyButton != null)
        {
            lobbyButton.onClick.RemoveAllListeners();
            lobbyButton.onClick.AddListener(OnClickLobby);
        }

        SetCurrentStage(0, 0, false);
        SetWaveProgress(0, 0);
        SetPlayTime(0f);
        // 내부 스테이지 시작 전 초기화
    }

    private void Update()
    {
        if (!timerRunning)
            return;

        totalPlayTime += Time.deltaTime;
        SetPlayTime(totalPlayTime);
        // 타이머 동작 중에만 표시, 경과 시간을 누적하고 누적된 시간을 UI에 표시
    }

    public void StartPlayTimer()
    {
        timerRunning = true;
        // 플레이 시간 측정 시작
    }

    public void StopPlayTimer()
    {
        timerRunning = false;
        // 플레이 시간 측정 멈추기
    }

    public void ResetPlayTimer()
    {
        totalPlayTime = 0f;
        SetPlayTime(totalPlayTime);
        // 누적 시간을 0으로 초기화
        // UI에도 00 00 반영
    }

    public void SetCurrentStage(int currentStage, int totalStage, bool infinite)
    {
        if (currentStageText == null)
            return;
        

        if (currentStage <= 0)
        {
            currentStageText.text = "Stage: -";
            return;
        }

        if (infinite)
            currentStageText.text = $"Stage: {currentStage}";
        else
            currentStageText.text = $"Stage: {currentStage}/{totalStage}";
        // 랜덤맵이 무한이면 스테이지 번호만 출력하고
        // 아니라면 현재 스테이지 / 전체 개수 표시
    }

    public void SetWaveProgress(int defeatedCount, int totalCount)
    {
        if (waveProgressText == null)
            return;
        

        if (totalCount <= 0)
        {
            waveProgressText.text = "Monsters: -";
            return;
        }

        int safeDefeated = Mathf.Clamp(defeatedCount, 0, totalCount);
        // 처치 수가 0보다 작거나 totalCount보다 커지지 않도록 보정
        waveProgressText.text = $"Monsters: {safeDefeated}/{totalCount}";
        // 몬스터 처치 진행률 표시
    }

    private void SetPlayTime(float seconds)
    {
        if (playTimeText == null)
            return;
        
        playTimeText.text = FormatTime(seconds); // 플레이 시간 00:00 문자열로 변환하여 표시
    }

    public IEnumerator WaitForStartRoundInput()
    {
        startRoundRequested = false;
        // 대기 없이 바로 넘어갈 수 있으므로 반드시 false로 초기화

        if (startRoundPanel == null || startRoundButton == null)
        {
            Debug.LogWarning("[InGameHUDController] Start Round UI is not assigned. Round starts automatically.");
            yield break;
        }

        if (startRoundHelpText != null)
            startRoundHelpText.text = "Prepare your base.";

        if (startRoundButtonText != null)
            startRoundButtonText.text = "Start Round";

        startRoundPanel.SetActive(true);
        startRoundButton.interactable = true;

        while (!startRoundRequested)
            yield return null;
        // 스타트 버튼 눌릴 때까지 매 프레임 대기

        startRoundButton.interactable = false; // 버튼 누른 뒤 중복 클릭 막기 위해 비활성화 해줌
        startRoundPanel.SetActive(false); // 라운드 시작하면 시작 버튼 패널 숨기기
    }

    public void ShowResult(bool cleared, string stageName)
    {
        StopPlayTimer();
        // 결과창이 표시되면 더 이상 플레이 시간이 증가하면 안되기 때문에 타이머 정지해줌

        if (resultPanel == null)
            return;

        resultPanel.SetActive(true); // 결과창 표시

        if (resultTitleText != null)
            resultTitleText.text = cleared ? "Stage Clear" : "Stage Failed";
        // 클리어 여부에 따른 성공 실패 텍스트 표시

        if (resultStageText != null)
            resultStageText.text = stageName;
        // 클리어 실패한 스테이지 이름 표시

        if (resultTimeText != null)
            resultTimeText.text = $"Time: {FormatTime(totalPlayTime)}";
        // 총 플레이 시간 표시
    }

    public IEnumerator PlayCenterBanner(string message)
    {
        if (centerBannerRoot == null || centerBannerText == null)
            yield break;

        centerBannerText.text = message;
        // 배너에 표시할 문구 설정

        centerBannerRoot.gameObject.SetActive(true);
        // 배너 오브젝트 활성화

        Vector2 startPos = bannerCenterPos + Vector2.down * bannerMoveDistance; // 배너 시작위치
        Vector2 endPos = bannerCenterPos + Vector2.up * bannerMoveDistance; // 중앙 보다 위쪽으로 빠져나감

        centerBannerRoot.anchoredPosition = startPos; // 시작 위치로 이동

        if (centerBannerCanvasGroup != null) // 알파값 조절로 투명하게 숨겨줌
            centerBannerCanvasGroup.alpha = 0f;

        float t = 0f;

        while (t < bannerInDuration)
        {
            t += Time.deltaTime;
            float p = Mathf.Clamp01(t / bannerInDuration);

            centerBannerRoot.anchoredPosition = Vector2.Lerp(startPos, bannerCenterPos, Smooth(p));
            // 시간계산을 통해 시작 위치에서 중앙 위치까지 부드럽게 이동시켜줌

            if (centerBannerCanvasGroup != null)
                centerBannerCanvasGroup.alpha = p;
            // 진행률에 따라 점점 불투명하게 만들어줌

            yield return null;
        }

        centerBannerRoot.anchoredPosition = bannerCenterPos;
        // 중앙 위치로 보정

        if (centerBannerCanvasGroup != null)
            centerBannerCanvasGroup.alpha = 1f;
        // 완전히 보이게 설정

        yield return new WaitForSeconds(bannerStayDuration);
        // 중앙에서 일정 시간 유지

        t = 0f;

        while (t < bannerOutDuration)
        {
            t += Time.deltaTime; // 배너 퇴장 시간 누적
            float p = Mathf.Clamp01(t / bannerOutDuration); // 0~1 사이 진행률 계산

            centerBannerRoot.anchoredPosition = Vector2.Lerp(bannerCenterPos, endPos, Smooth(p));
            // 중앙에서 위쪽 종료 위치로 이동

            if (centerBannerCanvasGroup != null)
                centerBannerCanvasGroup.alpha = 1f - p;
            // 점점 투명하게 만든다

            yield return null;
        }

        centerBannerRoot.gameObject.SetActive(false); // 배너 연출이 끝났으므로 비활성화
        centerBannerRoot.anchoredPosition = bannerCenterPos; // 다음 연출을 위해 원래 중앙 위치로 옮겨줌

        if (centerBannerCanvasGroup != null)
            centerBannerCanvasGroup.alpha = 1f; // 다음 연출을 위해 알파값 1로
    }

    public IEnumerator PlayCountdown(float duration)
    {
        if (countdownPanel == null || countdownRingFill == null || countdownNumberText == null)
        {
            yield return new WaitForSeconds(duration);
            yield break;
        }
        // 카운트 다운 UI 연결이 누락된 경우에도 게임 진행이 멈춰서는 안됌

        countdownPanel.SetActive(true);
        // 카운트다운 패널 표시

        float remaining = Mathf.Max(0.01f, duration); // 남은 시간 초기화

        while (remaining > 0f)
        {
            int number = Mathf.CeilToInt(remaining);
            // 나눈 시간 올림 해줌
            countdownNumberText.text = number.ToString(); // 카운트 다운 숫자
            countdownRingFill.fillAmount = Mathf.Clamp01(remaining / duration); 
            // 원형 게이지 설정 

            remaining -= Time.deltaTime;
            // 시간 감소

            yield return null;
        }

        countdownNumberText.text = "0"; // 마지막 숫자 0 표시
        countdownRingFill.fillAmount = 0f; // 원형 게이지 완전히 비우기

        countdownPanel.SetActive(false); // 카운트 다운 종료 후 패널 숨기기
    }

    private void OnClickStartRound()
    {
        startRoundRequested = true;
        // 스타트 버튼 누름
    }

    private void OnClickRetry()
    {
        SceneManager.LoadScene(gameSceneName);
        // 현재 게임 씬 다시 로드 
    }

    private void OnClickLobby()
    {
        StageSelectionStore.Clear(); // 현재 선택 정보 초기화
        SceneManager.LoadScene(lobbySceneName);
        // 로비로 돌아가기
    }

    private static string FormatTime(float seconds)
    {
        int totalSeconds = Mathf.FloorToInt(seconds);
        int minutes = totalSeconds / 60;
        int secs = totalSeconds % 60;

        return $"{minutes:00}:{secs:00}";
        // float 시간을 정수 초로 변환하여 00:00으로 텍스트 표출
    }

    private static float Smooth(float t)
    {
        return t * t * (3f - 2f * t);
        // 러프 보간을 통해 부드럽게 
    }
}

 

InGameHUDController

인게임에서의 hud, 인게임의 상황에 따라 정보 텍스트를 화면에 표출하여주는 역할

 

 

이렇게 현재 Lobby(Home 화면)에서 Start 버튼을 통해 ModeSelect 화면으로 넘어가게된다

여기서 싱글과 멀티 두개로 나뉘어 지는데  멀티는 아직 미구현 상태로 클릭 시 Matching 화면으로 넘어가며 매칭되는 시간을 보여주는 부분까지 구현하였고 싱글은 들어가게 되면 StageSelect 화면으로 넘어가게 되며 RamdomMap Stage와 선택을 통한 단계별 Stage로 드래그를 통해 Stage를 선택하여 Main씬인 인게임으로 들어가게 된다. 

 

영상은 나중에 정리해서 따로 올리는 방법을 찾아봐야겠다.. 20MB로는 플레이 영상이 안올라가지기에

나중 깃이나 유튜브를 통해 올리는 방안을 생각해보도록 하겠습니다

 

https://youtu.be/5J6U-rh4T7s

 

 

https://youtu.be/iwucw_i_2bo

 

728x90

'Unity' 카테고리의 다른 글

Unity(10) - Take Damage Event & UI  (0) 2026.04.28
Unity(9) - Random Spawn Monster & A*  (0) 2026.04.27
Unity(8) - Map Error Resolution  (0) 2026.04.13
Unity(7) - 3x3 Crafting Table  (0) 2026.03.13
Unity(6) - Atlas Block Map & Crafting Table (2)  (0) 2026.03.10