Unity/Project

[Unity] 내배캠 최종프로젝트 : 루시퍼 서바이벌 마무리

JEE_YA 2025. 9. 9. 20:28

마무리라고 작성할지는 몰랐지만 일단 어느순간부터 일기식의 TIL도 적지 않고 얼렁뚱땅 넘겻던것 같기에 스스로 돌아보고자 오랜만에 Unity와 티스토리를 켰다.

(오랜만에 블로그를 보던 중 부끄러워서 삭제하고싶던 게시글도 있고 추억이다 싶은 게시글도 있지만 마무리가 안되어있는걸 보고 속상하다 싶어 이렇게 정리하게 되었다, 삭제는 안하였지만 비공개 처리 돌린 게시글도 몇개 있다 ㅎㅎ..)

 

최종적으로 작업은 5월 말로 마무리, 발표는 6월 첫주에 끝이 났지만 이후 각자 하고싶은 작업에 대해 추가로 이어나가자 라곤 하였지만 나의 취업활동과 각자 개인사정으로 인해 나도 그렇지만 다른분들도 현재는 진행하지 않는듯 하며 개인 작업만 진행하시는듯 하다(이후로 연락을 못드린 내 탓이 크다....).

 

 그동안 진행하면서 내가 햇던 기능들에 대해 정리해보자면 아래와 같다.

 


맵 생성

맵 부분은 게시글로도 많이 올린적이 있었는데... 정말 많이 바뀌기도 했고 이랫다 저랫다 한 부분도 많아서 다시한번 정리!

 

  • MapManager에서 리스트를 통해 맵 프리펩 중 하나를 무작위로 선택해 Instantiate로 생성, 이전 생성된 맵이 있다면 제거 후 새로 생성되도록 처리,
  • 맵 끝에 도달 시 플레이어가 맵 밖으로 나가는 경우가 있어 벽 오브젝트를 맵 크기에 맞게 설치

사용 변수는 아래와 같다.

    [Header("Map Settings")]
    public Transform BaseMapBlock;
    [SerializeField] private List<GameObject> mapPrefabs;

    [Header("Wall Settings")]
    public GameObject wallPrefab;           // 벽 프리팹
    public Transform wallParent;            // 벽들의 부모 오브젝트
    public float wallSpacing = 2f;          // 벽 프리팹 간의 간격
    public float marginFromMap = 1f;        // 맵으로부터의 여백

    private GameObject selectedMapPrefab;
    private GameObject currentMap;
    private List<GameObject> generatedWalls = new List<GameObject>();

 

맵 생성의 경우 CreateMap() 메서드를 이용하였으며

처음엔 ClearMap()을 이용해 맵을 삭제, 이후 프리펩에있는 맵을 하나 가져와서 설치할 수 있게 해주었으며

    public void CreateMap()
    {
        ClearMap();

        int map = Random.Range(0, mapPrefabs.Count);
        selectedMapPrefab = mapPrefabs[map];
        currentMap = Instantiate(selectedMapPrefab, Vector3.zero, Quaternion.identity, BaseMapBlock);

        if (wallPrefab != null)
        {
            CreateWalls(); // 벽 생성 메서드
        }
    }

 

테스트 중 몇몇분이 한쪽만 뚫어! 라는 느낌으로 만들어놓은 맵 끝까지 가셧다가 떨어지는 사고가 있었다고 하여 프리펩을 이용하여 벽을 배치할 수 있게 매서드(CreateWalls())를 추가해주었다.

 private void CreateWalls()
 {
     Bounds mapBounds = GetCombinedBounds(currentMap);
     Transform parent = wallParent != null ? wallParent : BaseMapBlock;

     // 벽 프리팹의 실제 크기 계산
     Bounds wallBounds = GetPrefabBounds(wallPrefab);
     float wallWidth = wallBounds.size.x;
     float wallDepth = wallBounds.size.z;
     float wallHeight = wallBounds.size.y;

     Vector3 center = mapBounds.center;
     Vector3 size = mapBounds.size;

     // 벽을 더 안쪽으로 배치 (marginFromMap 값을 더 크게)
     float halfWidth = (size.x / 2f) - marginFromMap - (wallDepth / 2f);
     float halfDepth = (size.z / 2f) - marginFromMap - (wallDepth / 2f);

     // 맵 위에 올라오도록 Y 위치 조정
     float wallY = mapBounds.max.y + (wallHeight / 2f); // 맵 최고점 + 벽 높이의 절반

     // 북쪽 벽 (Z+)
     CreateWallLine(
         new Vector3(center.x - halfWidth, wallY, center.z + halfDepth),
         Vector3.right,
         halfWidth * 2,
         wallWidth,
         0f,
         parent
     );

     // 남쪽 벽 (Z-)
     CreateWallLine(
         new Vector3(center.x - halfWidth, wallY, center.z - halfDepth),
         Vector3.right,
         halfWidth * 2,
         wallWidth,
         0f,
         parent
     );

     // 동쪽 벽 (X+)
     CreateWallLine(
         new Vector3(center.x + halfWidth, wallY, center.z - halfDepth + wallWidth),
         Vector3.forward,
         (halfDepth * 2) - (wallWidth * 2),
         wallDepth,
         90f,
         parent
     );

     // 서쪽 벽 (X-)
     CreateWallLine(
         new Vector3(center.x - halfWidth, wallY, center.z - halfDepth + wallWidth),
         Vector3.forward,
         (halfDepth * 2) - (wallWidth * 2),
         wallDepth,
         90f,
         parent
     );
 }

 private void CreateWallLine(Vector3 startPos, Vector3 direction, float totalLength, float segmentSize, float rotationY, Transform parent)
 {
     // 겹치지 않도록 벽 개수 계산
     int wallCount = Mathf.FloorToInt(totalLength / segmentSize);

     // 실제 사용할 간격 계산 (약간의 여백 포함)
     float actualSpacing = segmentSize * 0.95f; // 5% 여백으로 겹침 방지

     // 시작 위치 조정 (중앙 정렬)
     float totalUsedLength = wallCount * actualSpacing;
     Vector3 adjustedStartPos = startPos + direction * ((totalLength - totalUsedLength) / 2f);

     for (int i = 0; i < wallCount; i++)
     {
         Vector3 position = adjustedStartPos + direction * (actualSpacing * i) + direction * (segmentSize / 2f);
         Quaternion rotation = Quaternion.Euler(0, rotationY, 0);

         GameObject wall = Instantiate(wallPrefab, position, rotation, parent);
         generatedWalls.Add(wall);
     }
 }

 

짧게 과정을 정리하면

1. 이 메서드는 먼저 맵의 크기를 계산한 뒤, 벽 프리팹의 크기(width, depth, height)를 확인하여 벽이 정확히 맞도록 조정한다.
2. 이후 벽 배치 위치를 계산하고, 높이는 mapBounds.max.y + (wallHeight / 2)를 사용해 맵 위에 딱 맞게 올린다.
3. 동서남북 방향(Vector3.right, Vector3.forward)으로 벽을 나열하며, CreateWallLine()을 통해 벽을 생성한다.
4. 이 과정에서 벽 사이가 겹치지 않도록 간격을 약간 조정하고, 중앙 정렬한 뒤 반복문으로 프리팹을 생성하여 리스트에 저장한다.

이렇게 하여 끝.


자원 생성

TileManager에서 생성하며 전체 타일만큼 셀을 순회, 무작위 위치에 배치하며 최소 거리를 유지해 겹치지 않도록 함

이부분은 아래 내용 참고

- 14일차 타일배치

- 15일차 배치지옥

 


밤 낮 구현

밤 낮 구현은 블로그에도 몇번 정리한적이 있었는데 최종적으론 바뀐 코드들이 좀 있기에... 다시 정리해서 올려보고자 한다.

올렸던 글은 17일차18일차 일부 

 

낮과 밤 상태 전환 (TimeState)

  • SetDay() → 태양 조명 켜기, 달 조명 끄기, UI 전환, 킬 카운트 리스너 활성화
  • SetNight() → 달 조명 켜기, 태양 조명 끄기, UI 전환, 킬 카운트 리스너 비활성화, 코루틴으로 밤 지속 시간 계산
    // 낮 전환
    public void SetDay()
    {
        if (currentTimeState != TimeState.Day)
        {
            currentTimeState = TimeState.Day;
            WaveManager.Instance.SetKillCountListener();
            StartLightingTransition();

            if (nightRoutine != null)
                StopCoroutine(nightRoutine);

            SetDayAndNight();

            if (battleScreen != null && battleScreen.gameObject.activeInHierarchy)
                battleScreen.UpdateBattleScreenState();
        }
    }

    // 밤 전환
    public void SetNight()
    {
        if (currentTimeState != TimeState.Night)
        {
            currentTimeState = TimeState.Night;
            WaveManager.Instance.RemoveKillCountListener();
            StartLightingTransition();
            if (battleScreen != null && battleScreen.gameObject.activeInHierarchy)
                battleScreen.UpdateBattleScreenState();

            if (nightRoutine != null)
                StopCoroutine(nightRoutine);

            SetDayAndNight();

            // 밤 시계 표시 코루틴 시작
            nightRoutine = StartCoroutine(NightTimeProcess());
            SetWaveText();

            // 밤으로 전환됐으므로 밤->낮 타이머 설정
            if (enableNightTimer)
            {
                SetNightTimer();
            }
        }
    }

 

조명 전환 (StartLightingTransition)

  • 낮↔밤 전환 시 LightTransitionCoroutine() 코루틴을 통해 태양/달 밝기, 색상 주변광/반사광의 세기를 부드럽게 보간
private void StartLightingTransition()
{
    if (transitionRoutine != null)
        StopCoroutine(transitionRoutine);

    transitionRoutine = StartCoroutine(LightTransitionCoroutine());
}

private IEnumerator LightTransitionCoroutine()
{
    float timeElapsed = 0f;

    float startSunIntensity = sun.intensity;
    float targetSunIntensity = (currentTimeState == TimeState.Day) ? dayLightIntensity : 0f;

    float startMoonIntensity = moon.intensity;
    float targetMoonIntensity = (currentTimeState == TimeState.Night) ? nightLightIntensity : 0f;

    float startAmbient = RenderSettings.ambientIntensity;
    float targetAmbient = (currentTimeState == TimeState.Day) ? dayAmbientIntensity : nightAmbientIntensity;

    float startReflection = RenderSettings.reflectionIntensity;
    float targetReflection = (currentTimeState == TimeState.Day) ? dayReflectionIntensity : nightReflectionIntensity;

    Color startSunColor = sun.color;
    Color targetSunColor = (currentTimeState == TimeState.Day)
        ? new Color(1.0f, 244f / 255f, 214f / 255f)
        : new Color(0.2f, 0.2f, 0.2f);

    moon.gameObject.SetActive(true); // 항상 켜두고 밝기만 조절

    while (timeElapsed < transitionDuration)
    {
        float t = timeElapsed / transitionDuration;

        sun.intensity = Mathf.Lerp(startSunIntensity, targetSunIntensity, t);
        sun.color = Color.Lerp(startSunColor, targetSunColor, t);

        moon.intensity = Mathf.Lerp(startMoonIntensity, targetMoonIntensity, t);

        RenderSettings.ambientIntensity = Mathf.Lerp(startAmbient, targetAmbient, t);
        RenderSettings.reflectionIntensity = Mathf.Lerp(startReflection, targetReflection, t);

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

    // 보정
    sun.intensity = targetSunIntensity;
    sun.color = targetSunColor;

    moon.intensity = targetMoonIntensity;
    moon.gameObject.SetActive(currentTimeState == TimeState.Night);

    RenderSettings.ambientIntensity = targetAmbient;
    RenderSettings.reflectionIntensity = targetReflection;

    transitionRoutine = null;
}

 

밤 지속시간

  • SetNightTimer()에서 밤 시작 시 타이머 설정 → 일정 시간이 지나면 자동으로 낮으로 전환
    private void SetNightTimer()
    {
        if (!enableNightTimer || currentTimeState != TimeState.Night)
            return;

        enableNightTimer = false;
        float duration = WaveManager.Instance.curWave?.NightTime ?? defaultNightDuration;

        this.DelayedCall(duration, () =>
        {
            if (currentTimeState == TimeState.Night)
                StageManager.Instance.ChangeToDay();

            enableNightTimer = true;
        });
    }

 


기타 UI 기능

설정창의 경우 원래 UI 담당이 아니었기도 하고 기존에 코드들이 다 잘 작성되어 있어 활용하였던 점이 커서 사실 내가한게 많이 없다 ㅎㅎ..

일시정지의 경우 GameManager에 있는 PauseGame() 메서드 사용

public class StagePauseBtn : MonoBehaviour
{
    [SerializeField] public Button button;

    [SerializeField] private GameObject StageOptionUI;
    [SerializeField] private Transform canvas;

    public void OpenWindow()
    {
        canvas = StageUIManager.Instance.GetCanvasTransform();

        GameManager.Instance.PauseGame(0f);
        StageUIManager.Instance.GetCanvasTransform();
        var StageSelectWindow = Instantiate(StageOptionUI, canvas);
    }
}

 

 

 


튜토리얼

튜토리얼은 예정에는 없었는데, 게임을 플레이해보다 보니 처음에 알려주는 내용이 없으면 게임하기가 너무 어렵겠더라... 모드를 만들고싶은 욕심은 컷지만 시간관계상 어려웠기 때문에 의견을 제시하고 내가 그냥 이미지 형식으로 만들어버렸다.

 

  • 사용자가 알기쉽게 튜토리얼 화면구성 및 기능 추가, PlayerPrefs를 이용하여 최초 1회 실행시에만 노출
  • 리스트를 사용하여 페이지 프리펩을 넣어주고, 버튼을 연결하여 페이지 전 후 기능 실행

   [SerializeField] private GameObject[] pages; // 페이지 리스트
   [SerializeField] private GameObject tutorial;
   [SerializeField] private Button nextBtn;
   [SerializeField] private Button prevBtn;

   private int currentPage = 0;

   private new void Awake()
   {
       if (PlayerPrefs.GetInt("TutorialShown", 0) == 1)
       {
           gameObject.SetActive(false);
       }
   }

   private void Start()
   {
       ShowPage(0);
       nextBtn.onClick.AddListener(NextPage);
       prevBtn.onClick.AddListener(PrevPage);
   }

   private void ShowPage(int index)
   {
       for (int i = 0; i < pages.Length; i++)
       {
           pages[i].SetActive(i == index);
       }

       currentPage = index;
       prevBtn.gameObject.SetActive(currentPage > 0);
       nextBtn.gameObject.SetActive(true);
   }

   public void NextPage()
   {
       if (currentPage < pages.Length - 1)
       {
           ShowPage(currentPage + 1);
       }
       else
       {
           EndTutorial();
       }
   }

   public void PrevPage()
   {
       if (currentPage > 0)
       {
           ShowPage(currentPage - 1);
       }
   }

   private void EndTutorial()
   {
       // 튜토리얼 UI 비활성화
       tutorial.SetActive(false);

       // 첫 실행 여부 저장
       PlayerPrefs.SetInt("TutorialShown", 1);
   }

 

총 6페이지로 구성되어 있으며 테스트용으로 PlayerPrefs 초기화 메뉴를 추가해주었다.

 


여기까지가 내가 프로젝트 진행 기간동안 했던 작업들이며

사실상 긴 기간동안 팀원들이 기초부터 차근차근 쌓을 수 있게 도와주시기도 하여 실력은 늘었다고 생각하나 그만큼 공부에 집중하여 작업량은 그다지 많지 않았다.

다만 나한텐 정말 큰 경험이 되었다고 생각했고, 이후에는 내가 UI쪽으로 공부하고 싶어서 해상도 부분부터 차근차근 시작하려 하였으나........ 해상도 기능은 만들었는데 해상도가 안바뀌어서 멘탈이 나가있던 중 취업이슈로 손을 대지 못하였다...

 

이렇게 내배캠도, 최종 프로젝트도 마무리 되었는데 좋은 시간과 좋은 팀원덕에 진짜 좋은 경험을 얻었던것 같지만 시간에 비해 많이 하지 못하고 더 몰두하지 못한 점은 좀 아쉽다고 생각한다.

그리고 잠시 사회생활 하면서 잦은 야근이슈로 개발에 손대지 못하고 있었는데... 정리하다 보니 뇌가 굳은 느낌이라 앞으로 다시 재활하면서 열심히 해야겠단 생각이 들었따.

최종 발표회장 사진

 

앞으로도 파이팅이다 :)