https://github.com/lostwaltz/PossibleDefense

 

GitHub - lostwaltz/PossibleDefense

Contribute to lostwaltz/PossibleDefense development by creating an account on GitHub.

github.com

 

일주일간 4명의 팀원과 진행한 모바일 3D 타워 디펜스 게임에 프로토타입을 개발하였다.

결과물은 만족 스럽게 나왔으나 코드 작성시에 하드코딩이 좀 많았던것 같아 회고를 해보려고 한다. 

 

해당 파트에서 나는 Stage에 관련된 파트를 담당했다.

 

Stage에서 필요한 기능들은

-Stage에 사용될 Tile 관리

-Tile에 설치될 타워 관리 (설치 및 판매)

-해당 Stage에서 사용될 타워 업그레이드

-Stage에서 사용되는 골드 재화

-로비 -> 게임 -> 로비 로 이동시의 Data 유지와 Scene 연결

요약하면 이정도가 될것같다. 

 

해당 기능들을 크게 키워드로 해서 Class로 기능을 분리해서 작업해야 가독성이 오르는데

이번에는 하드코딩도 많고 팀원끼리 소통을 그렇게 많이 했는데도 변수명을 정하지 않았던점이 문제였던것 같다.

GameScene에서 사용된 오브젝트들과 컴포넌트들인데, 전혀 가독성이 없고 코드가 일관성이 없어 보기 힘들다.

1번 문제점 :

UI매니저가 없기에 우선 UI를 만들어두자 하고 작업을 진행하였고 Refactoring 하는 과정에서

UI매니저를 작성하여 UI매니저에 집어넣고 꺼내쓰는 식으로 사용 했어야 됬는데 결국에는 UI매니저를 사용하기 위해 UIBase 인터페이스 자체도 작성하지 않아 결국에는 동적생성을 하지 못해 이런식으로 사용하게 되었다.

 

1번 문제의 대처점 : 첫 작업시에 프레임워크 작업을 진행하고 , 해당 매니저들을 미리 작성해놓아야한다.

그러기에 팀원과 회의를 정말 많이 해야된다. 

 

2번 문제점 :

3번과 연관되는 문제점이긴한데 SpawnManager가 있지만 이 Manager는 EnemySpanwManager이다.

이름이 직관적이지 않아 직접 열어봐야만 알수있고 BulletObjectPool은 하이어라키에있고

Tile ObjectPool은 왜 Stageamanager의 안에 있는지 의문이다. ( ObjectPoolLegacy가 Tile ObjectPool)

즉, Class 및 오브젝트의 이름이 직관성 없기때문에 발생했던 문제다.

 

2번 문제의 대처점 : 해당 오브젝트 명 컴포넌트 명을 정확하게 구체적으로 작성해서 사용 할것 

 

3번 문제점

위에서 말한 큼직큼직한 기능들을 키워드 삼아서 Class별로 분리해서 컴포넌트처럼 붙여 사용하거나 따로 오브젝트화 시켰어야했는데 StageManager에 관련없는 기능을 다 넣어서 사용 한것이 문제가 되었다.

 

정말 하드코딩하고 Inspector에 다 때려 밖은 상태 절대 이렇게 작업하면 안된다.

 

StageManager의 Inspector에서 보면 밑에 컴포넌트로 부착한것들을 전부 등록하여 사용하고있다.

물론 내부에서도 해당 컴포넌트들이 비면 null체크를 해서 컴포넌트를 호출해서 사용하고 있지만

이런 Inspector 구조는 가독성이 떨어지기에이런식으로 Inspctor에 정리없이 사용하면 안될것 같다. 

또한 Scene 전환시에도 Missing이 일어날 확률이 높다.

 

3번 문제의 대처점 : Inspector에서는 최소한으로 등록하여 사용하고 내부적으로 동적생성하게 코드를 구현해야 되며 UI매니저를 사용하여 UI를 원하는 시점에서 꺼내 쓸수 있게 구현 해야된다.

 

 

p.s 코드 구조를 잡지 않고 자료구조를 막 사용한 나쁜 예시

이 코드는 제출하기 직전에 급하게 만든 코드라 최적화를 생각하지 못했다.

타일을 선택할떄마다 타일 선택 정보를 초기화 해주는 코드인데 맨처음 작성시 자료구조를 List를 쓴것부터가 문제의 시작이였고 해당 문제를 해결하려면 TowerTiled을 딕셔너리로 변경해서 사용할수있게 구조를 바꿧어야 했다. 

 

 

그래도 해당 프로젝트에서는 오전 / 오후 / 저녁으로 자기의 코드리뷰와 프로젝트 진척도를 회의하는 시간을 계속 가져왔고

작업량이 없어도 무조건 코드리뷰를 하게 하였다. 그러면서 서로 문제점이 있는부분을 서로 보완해주고 서로에 대한 정보공유를하면서 정말 실력이 많이 늘었다. 

 

다음 프로젝트에서도 팀원과의 소통 및 회의/회고를 적극 권장해야겠다.

위에 조건처럼 [어떤 동작을해도 같은 목적을 취함 + 동작이 다름] 이라는 조건하에 전략패턴커맨드패턴의 디자인패턴을 자주 사용할 것이다. 하지만 UI의 버튼이나 규모가 작은 경우에는 전략패턴을 구조로 설계하기에 리소스 낭비적인 부분이 있다. 

 

이런 경우에는 <매핑 테이블 활용>을 하여 각 기능을 정의한뒤 인자값을 다르게 설정하는 것이다. 

 

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using Unity.Burst.Intrinsics;

public class UpgradesController : MonoBehaviour 
{
    [SerializeField] private SlimeTowerStatUpgradeData[] _upgradeDatas;
    [SerializeField] private Button[] buttons;

    private Dictionary<Button, TowerGrade> upgradeButtonMappings;

    private void Awake()
    {
        upgradeButtonMappings = new Dictionary<Button, TowerGrade>
        {
            { buttons[0], TowerGrade.Common},
            { buttons[1], TowerGrade.Normal},
            { buttons[2], TowerGrade.Epic},            
        };

        foreach(KeyValuePair<Button, TowerGrade> pair in upgradeButtonMappings)
        {
            pair.Key.onClick.AddListener(() => UpgradeTowerByGrade(pair.Value));
        }
    }

    //버튼 누르면 해당 등급에 따라 강화 
    public void UpgradeTowerByGrade(TowerGrade grade)
    {
        foreach (var data in _upgradeDatas)
        {
            if (data.Grade == grade)
            {
                data.OnUpgrade();
                break;
            }
        }
    }
}

위 예제 코드는 버튼이 3개가 있고 , 해당 버튼들은 해당 등급의 유닛들을 강화해주는 기능이있다.

목적은 강화기능이기에 등급이라는 인자값을 받아서 적용시켜주어야한다. 

 

초기화시 사용할 데이터를 Inspector를 통해서 초기화한뒤(스크립트 코드로 적용시켜도 됨)

버튼의 onClick.AddListener로 람다식을 이벤트로 초기화 하여 사용하는 방식을 취한다.

 


이런식으로 테이블매핑을 한뒤 각 기능에 맞춰 이벤트를 적용시켜주면 전략패턴을 사용하지않고

간단히 사용 할 수 있다.

파일 경로명을 인자값으로 받아서 CSV데이터를 읽어 파싱한 후  List<Vector3>로 반환한다.

반환값이 T가 아닌 고정되어있어 범용성이 떨어지는 메서드이다. 나중에 Refactoring이 필요해보인다. 

 

Stage Way Point는 Vector3를 저장하는 CSV 외부데이터이다.

 

  public static List<Vector3> LoadStageWayPointFromCSV(string fileName)
  {
      // Resources 폴더에서 파일 로드
      TextAsset csvFile = Resources.Load<TextAsset>(fileName);

      if (csvFile == null)
      {
          Debug.LogError($"File {fileName} not found in Resources folder.");
          return null;
      }

      // 데이터를 한 줄씩 분리
      string[] lines = csvFile.text.Split('\n');
      List<Vector3> wayPointList = new List<Vector3>();

      //첫번쨰 줄은 헤더이기에 제외 
      for (int i = 1; i < lines.Length; i++)
      {

          string line = lines[i].Trim(); // 공백 제거
          if (string.IsNullOrEmpty(line)) continue; // 빈 줄 무시

          string[] values = line.Split(',');

          if (int.TryParse(values[0], out int x) &&
              int.TryParse(values[1], out int y) &&
              int.TryParse(values[2], out int z))
          {
              wayPointList.Add(new Vector3Int(x, y, z));
          }
      }

      return wayPointList;
  }

비록 간단한 내용이지만, 생각밖으로 놓치고 갈 수도 있을것 같아 정리를 하였다.

 

0.생명주기 함수(Update,Start)들을 사용하지 않으면 무조건 지워라

이 함수들은 [리플렉션]이란 것때문에 호출만 하고 있어도 동작을 하여 처리비용을 계속 잡아먹기 때문이다. 

 

p.s 리플렉션

리플렉션은 컴파일 시에 알 수 없었던 타입이나 멤버들을 찾아내고 사용할 수 있게 해주는 메커니즘이다.

리플렉션 정보는 해당 블로그 글을 참고하자.

https://tsyang.tistory.com/56

 

C# - 리플렉션 (Reflection)

리플렉션 리플렉션은 컴파일 시에 알 수 없었던 타입이나 멤버들을 찾아내고 사용할 수 있게 해주는 메커니즘이다. 그러나 다음의 주요한 단점이 존재한다. 리플렉션을 사용하면 컴파일 시에

tsyang.tistory.com

 

1. 빌드시에 Debug.Log()를 지우고 사용하기

빌드시에도 코드영역에 Debug.Log()가 남아있는경우 여전히 처리비용이 들며 , 해킹의 여지도 있게된다.

왜냐하면 개발시에 디버깅용으로 넣은 Debug.Log()로 데이터를 파악하기떄문이다.

 

2.LinQ함수 남용 금지

Linq 패키지로 구현이 가능한건 for과if문으로 구현이 가능하기 때문이며 , Linq는 생각밖으로 처리비용이 높기때문이다.

(Linq와 for문 루프 돌린 성능 비교 그래프 참조 필요함)

 

3.코루틴 사용시 yield return new WaitForSeconds(); 사용 지양하기

사실 코루틴은 반복하게 하는 메서드이지만 new라는 키워드로 인해서 결국 반복적으로 객체를 생성하고

코루틴이 사용이 완료되면 해당 데이터는 가비지가 되어 결국 최적화에 영향을 끼친다.

WaitForSeconds updateTime = new WaitForSeconds(1.0f);

이런식으로 class내에 멤버변수로 선언하고 사용하는것이 올바르다. 

 

4.오브젝트 풀링시 대량의 오브젝트 생성시 자식오브젝트로 생성하지 마라

하이어라키에 오브젝트 풀링을 정리하겠다고 부모오브젝트를 생성하고 그 밑에 만드는데

결국 하이어라키는 계층구조(tree)이기떄문에 나중에 위치가 이동되거나 할때 문제가 발생하기 때문이다. 

이번 개인 프로젝트 진행중에 Player,Enemy,Item의 데이터를 ScriptableObject 로 관리 해보았다.

또한 저장/불러오기 기능을 ScriptableObject 로 구현해보기 위해서였다.

 

하지만 Player,Enemy의 Status 데이터들은 계속 변동 되는 값이였기에 나중에 게임을 다시 시작하거나 하면 데이터가 유지는 되는데 아이템을 장착하지 않았는데 공격력이 적용 되어 있거나 그랬다.

 

Item처럼 불변되지 않는값에 ScriptableObject 를 사용하여 데이터를 호출하는 목적으로만 사용하는것이 좋은것 같다,. 

[문제점]
Enemy Idle Class에서 Enemy 의 변수인 Data(EnemyData 스크립타블오브젝트)에 Collider변수를 저장한뒤
Enemy Chase Class에서 SO를 호출하니 null이 반환되는 건

 

[첫번쨰 대처]

1. Collider -> GameObject로 타입으로 수정한뒤  Collider의 정보가 필요할때 TryGetComponet를 이용해서 호출하였지만 되지 않았음 

 

해당 대처를 한 이유 : 

ScriptableObject에서 Collider와 같은 Unity 컴포넌트 타입은 일반적인 값이나 데이터처럼 직접 저장하거나 유지할 수 없습니다.  ScriptableObject는 주로 데이터만 저장하기 위한 용도로 설계목적이기 때문

 

디버깅중 찾은 원인은 Enemy가 하나만 있는경우 상관이 없지만
Enemy가 여러명이 되면서 해당 Data에 접근해서 계속 Data를 바꾸게 로직을 작성함

 

즉, ScriptableObject는 참조형식(Class) 이기에 A Class에서 사용중이다가 B Class값을 변경하게되면 A Class에서 사용하던 데이터가 사라지기 때문이였다. 

Class => 참조형식이다.

[해결방법]

Enemy Data(스크립타블오브젝트)에서는 Data 호출만하게 하고

Enemy의 Class로직을 담당하는곳에 따로 필요한 변수(Target)를 선언하여 사용하기로 함 

 

MVC 패턴이란 Model - View - Controller의 약자이며 , Unity 에서 개임 개발시 주로  UI에 사용된다.

 

Model : 데이터를 의미하며 , DB를 만들어 데이터를 보관하는 목적이다.

데이터는 엑셀을 이용해서 외부에서 파싱해서 사용 할 수 있다.

데이터들은 ID 라는 int 정수값을 이용해서 사용 하는것이 매우 유리하다.

 

View : 유저들에게 눈으로 보여지는 것을 의미하며 유니티로는 Canvas의 UI를 의미하며,

UI 스크립트에는 로직이 아닌 오로지 출력이 필요하다. 출력이 필요한 데이터는 컨트롤러를 통해서 전달 받아야한다.

 

Controller : Model과 View의 중간 다리 역할이라고 보면된다. 즉, Manager와 역할이 비슷하며

해당 영역에 로직을 작성하여 Model과 View를 Update를 해주는 역할이다. 

브릿지 패턴

템플릿메소드패턴

경령화패턴 설계

UI - MVC / MVP / MVVM

+ Recent posts