개발/유니티

[유니티] 자바에서 만든 프로젝트 유니티로 옮기기(3)

kimchangmin02 2025. 7. 25. 17:13

1

현재 객체풀 게임오브젝트에 아무것도 연결이 안된건가?
이렇게해도되나

 

왜 비어있는 것이 정상인가? (책임의 분리)

이전 시스템에서는 ObjectPooler가 모든 것을 다 알아야 했습니다.

이전 방식 (수동 관리):
"ObjectPooler야, 너는 Enemy도 알아야 하고, Bat도 알아야 하고, PlayerShot도 알아야 해. 내가 네 인스펙터에 전부 다 등록해 줄게."

단점: 몬스터를 추가할 때마다 GameManager ObjectPooler 두 군데를 모두 수정해야 하는 번거로움과 실수의 위험이 있었습니다.

우리가 새로 만든 자동화 시스템은 이 책임을 명확히 나눴습니다.

현재 방식 (자동 관리):

  • GameManager의 책임: "나는 이 게임에 어떤 몬스터들이, 어떤 순서로 등장할지 모든 것을 아는 유일한 존재다."
  • ObjectPooler의 책임: "나는 몬스터 종류는 잘 몰라. 그냥 게임이 시작되면 GameManager에게 가서 '준비해야 할 몬스터 목록 전부 주세요' 라고 물어보고, 그 목록대로 풀을 자동으로 만들 뿐이야. 나는 GameManager가 모르는 총알이나 이펙트 같은 것들만 수동으로 관리할게."

바로 이 ObjectPooler의 새로운 책임 때문에, 인스펙터에 Manual Pools(수동 관리 목록)라는 항목만 남고, 그곳에 몬스터를 연결할 필요가 없어진 것입니다.

그럼 무엇을 연결해야 하나?

스크린샷을 보니 Manual Pools 리스트가 비어있습니다. 이 상태에서는 몬스터는 자동으로 생성되지만, PlayerShot(플레이어 총알)이나 ExperienceOrb(경험치 구슬)는 풀이 만들어지지 않아 스폰되지 않을 것입니다.

이제 남은 작업은 GameManager가 알지 못하는 것들만 이 Manual Pools에 추가해주는 것입니다.

해결 방법:

  1. @ObjectPooler 게임 오브젝트를 선택합니다.
  2. 인스펙터 창의 Manual Pools 리스트 옆에 있는 + 버튼을 두 번 클릭하여 두 개의 칸을 만듭니다.
  3. 각 칸에 아래와 같이 총알과 경험치 구슬의 정보를 입력합니다.
    • Element 0 (총알 풀):
      • Tag: "PlayerShot" (총알을 쏘는 스크립트에서 사용하는 태그)
      • Prefab: PlayerShot.prefab 을 프로젝트 창에서 끌어다 놓습니다.
      • Size: 30
    • Element 1 (경험치 구슬 풀):
      • Tag: "ExperienceOrb" (Enemy 스크립트에서 사용하는 태그)
      • Prefab: ExperienceOrb.prefab 을 프로젝트 창에서 끌어다 놓습니다.
      • Size: 100

 

 

 

 

 

 

2

근데 코드상으로, 뭘 어떻게 구현한거지

비유: 중앙 기획팀과 공장장의 대화

  • GameManager (중앙 기획팀): 우리 회사에서 앞으로 생산할 모든 제품(몬스터)의 목록을 가지고 있는 유일한 팀.
  • ObjectPooler (공장장): 제품을 실제로 생산(풀 생성)하는 책임자.

이전 방식 (수동):
공장장은 기획팀과 소통하지 않았습니다. 누군가가 공장장에게 찾아와 "슬라임 생산 라인 만들어주세요", "박쥐 생산 라인도요" 라고 일일이 수동으로 요청해야 했습니다. (우리가 인스펙터의 Pools 리스트에 수동으로 추가하던 방식)

새로운 방식 (자동):
게임이 시작되면, 공장장(ObjectPooler) 이 똑똑하게 먼저 중앙 기획팀(GameManager) 에 전화를 겁니다.

공장장: "기획팀장님, 이번 분기에 생산해야 할 모든 제품 목록을 전부 보내주십시오."
기획팀: "네, 여기 있습니다. (슬라임, 박쥐, 문어 목록 전달)"
공장장: "알겠습니다. 이 목록을 보고 제가 알아서 생산 라인을 전부 구축하겠습니다."

이 '전화 통화'와 '목록 전달' 과정이 바로 코드의 핵심입니다.


코드 심층 분석: 두 단계의 자동화

1단계: GameManager가 '전체 몬스터 목록'을 준비하는 과정

GameManager.cs에 추가된 이 새로운 함수가 바로 '기획팀의 보고서' 역할을 합니다.

Generated csharp

// GameManager.cs 의 일부
public List<GameObject> GetAllUniqueMonsterPrefabs()
{
    // 1. 중복을 허용하지 않는 특별한 가방(HashSet)을 준비합니다.
    HashSet<GameObject> uniqueMonsters = new HashSet<GameObject>();

    // 2. 인스펙터에 설정된 모든 레벨(Level 1, Level 2...)을 하나씩 훑어봅니다.
    foreach (LevelData levelData in levelProgression)
    {
        // 3. 각 레벨의 '스폰 가능 몬스터 목록'을 또 하나씩 훑어봅니다.
        foreach (GameObject monsterPrefab in levelData.spawnableMonsters)
        {
            // 4. 그 몬스터를 중복 없는 가방에 넣습니다.
            // (만약 가방에 이미 'blueSnail'이 있다면, 또 넣으려고 해도 무시됩니다.)
            uniqueMonsters.Add(monsterPrefab);
        }
    }

    // 5. 중복이 모두 제거된 가방을 일반 리스트로 변환하여 보고합니다.
    return uniqueMonsters.ToList();
}

이 함수 덕분에 GameManager는 언제든 "우리 게임에 나오는 모든 종류의 몬스터 목록"을 제공할 준비가 되었습니다.

2단계: ObjectPooler가 목록을 받아 '자동으로 풀을 생성'하는 과정

ObjectPooler.cs Start() 함수가 바로 '공장장이 생산 라인을 구축하는' 과정입니다.

Generated csharp

// ObjectPooler.cs 의 일부
void Start() // Awake()가 아닌 Start()에서 실행하는 이유: 모든 GameManager의 준비가 끝난 후 실행하기 위함
{
    poolDictionary = new Dictionary<string, Queue<GameObject>>();

    // --- 몬스터 풀 자동 생성 파트 ---
    Debug.Log("--- 몬스터 풀 자동 생성을 시작합니다 ---");

    // 1. "전화 통화": GameManager의 유일한 인스턴스에 접근해, 전체 몬스터 목록을 달라고 요청합니다.
    List<GameObject> monsterPrefabs = GameManager.Instance.GetAllUniqueMonsterPrefabs();

    // 2. "생산 라인 구축": 받아온 목록에 있는 몬스터들을 하나씩 보면서 풀을 만듭니다.
    foreach (GameObject monsterPrefab in monsterPrefabs)
    {
        // (이 안의 로직은 기존과 동일합니다)
        Queue<GameObject> objectPool = new Queue<GameObject>();
        for (int i = 0; i < defaultMonsterPoolSize; i++)
        {
            GameObject obj = Instantiate(monsterPrefab);
            obj.SetActive(false);
            objectPool.Enqueue(obj);
        }
        // "생산 라인 이름표 붙이기": 딕셔너리에 프리팹의 이름("otapos")을 태그로 사용하여 풀을 등록합니다.
        poolDictionary.Add(monsterPrefab.name, objectPool);
        Debug.Log("자동 생성 완료: " + monsterPrefab.name + " 풀");
    }

    // --- 수동 설정 풀 생성 파트 (총알, 경험치 구슬 등) ---
    // GameManager가 모르는 것들은 예전처럼 수동으로 처리합니다.
    foreach (Pool pool in manualPools)
    {
        // ... (이 부분은 기존과 동일)
    }
}
 

결론: 코드의 흐름 요약

  1. 게임이 시작되면 모든 Awake()가 먼저 실행됩니다. (GameManager.Instance가 자기 자신을 등록)
  2. 그다음 모든 Start()가 실행됩니다.
  3. ObjectPooler.Start()가 실행되면서, GameManager.Instance.GetAllUniqueMonsterPrefabs()를 호출합니다.
  4. GameManager는 자신의 Level Progression 리스트를 모두 스캔하여, 중복을 제거한 순수한 몬스터 프리팹 목록 (blueSnail, Enemy, otapos)을 반환합니다.
  5. ObjectPooler는 이 목록을 받아서, 목록에 있는 각 몬스터(blueSnail, Enemy, otapos)에 대해 foreach 반복문을 돌며 각각의 풀을 생성하고 poolDictionary에 등록합니다.
  6. 이어서 manualPools에 등록된 PlayerShot, ExperienceOrb 등의 풀도 생성합니다.

이로써 우리는 데이터의 출처를 GameManager 한 곳으로 통일하고, ObjectPooler는 그 데이터를 가져다 쓰는 똑똑한 자동화 공장으로 탈바꿈시킨 것입니다. 이제 인스펙터에서 ObjectPooler Manual Pools 리스트가 비어있는 것은, "몬스터에 관한 한, 나는 이제 수동으로 관리받지 않고 GameManager에게 알아서 물어봅니다." 라는 의미가 됩니다.

 

 

 

 

 

 

3

근데 체력바 옆에, 현재체력/최대체력

그리고 경험치에 대해서
현재 경험치/레벨업하기위한 최대 경험치
에 해당하는 틀이 필요한데

 

이것도, 캔버스 추가해야겟지 ?

근데 이건 체력바는 아닌데

slider이 필요한가

 

아,근데 이건 나중에 할까

ui는 지금도 나쁘지가 않은것같은

 

 

 

 

 

4

또한 레벨업하게 되면, 
선택지가 뜨고, 그중, 선택지를 누르면 그만큼 강해지게도 하고싶은데
자바에서는 gui중 카드 레이아웃으로 해결햇엇는데

유니티에서는 또 캔버스 이용하면 되겟지?


또한 게임 멈춤을 구현도 해야겟지?
고를동안은?

그럴려면, gamManager에서, isGamePause변수도 만들어놔야겟지 ?


근데 자바에서는 문자열을 뽑는 형식으로 햇잖아
근데 좀더 좋은 방식이 있으려나

 

 

왜 자바의 문자열 방식보다 더 좋은가? (ScriptableObject 소개)

자바 코드에서 rewardMap.put("Fire", new FireBall()) 처럼 문자열("Fire")을 키로 사용하는 방식은 오타에 취약하고, 새로운 보상을 추가할 때마다 여러 군데의 코드를 수정해야 하는 단점이 있습니다.

유니티의 ScriptableObject는 이 문제를 완벽하게 해결합니다. 이것은 '데이터를 담을 수 있는 에셋(파일)'을 만드는 기술입니다.

  • 비유: FireballReward.asset, HealthUpgradeReward.asset 같은 '보상 설계도' 파일을 프로젝트 창에 직접 만들어 둘 수 있습니다.
  • 장점:
    • 타입 안전성: 문자열이 아닌 '파일' 자체를 참조하므로 오타가 원천적으로 불가능합니다.
    • 중앙 관리: 모든 보상 데이터를 코드 바깥의 파일로 관리하므로, 기획자나 디자이너도 쉽게 값을 수정할 수 있습니다.
    • 확장성: 새로운 보상을 추가할 때, 새로운 '설계도' 파일을 하나 더 만들기만 하면 되므로 코드 수정이 최소화됩니다.

 

 

 

 

 

 

 

5

근데 게임이 일시정지하는건 어케 구현햇지?
몬스터 안움직이나
isGamePuase true이면 ??

 

 

결론부터 말씀드리면, isGamePaused = true 라는 변수 자체는 몬스터를 멈추게 하는 힘이 없습니다.

실제로 몬스터와 게임 전체를 멈추는 마법의 코드는 GameManager 안에 있는 Time.timeScale 이라는 유니티 내장 기능입니다.


비유: TV 리모컨과 전원 표시등

이 두 가지의 관계를 TV 리모컨에 비유하면 아주 쉽습니다.

  • isGamePaused 변수: TV 앞에 있는 '전원 표시등' 램프 💡
    • 이 램프는 현재 TV가 켜져 있는지(false), 꺼져 있는지(true) 상태를 보여주기만 할 뿐, 스스로 TV를 켜거나 끌 수는 없습니다.
  • Time.timeScale 속성: 우리가 손에 쥔 '리모컨의 실제 전원 버튼' ⏯️
    • 이 버튼을 눌러야만 TV가 실제로 꺼지고(일시정지), 다시 켜집니다(재개).

GameManager는 이 리모컨과 표시등을 모두 가지고 있는 '사람'입니다.

어떻게 몬스터가 멈추는가? (Time.timeScale의 마법)

GameManager.cs PauseGame() 함수를 자세히 보겠습니다.

Generated csharp

public void PauseGame()
{
    // 1단계: "전원 표시등"을 '꺼짐' 상태로 바꾼다.
    isGamePaused = true; 

    // 2단계: "리모컨의 일시정지 버튼"을 실제로 누른다.
    Time.timeScale = 0f; 
}

Time.timeScale = 0f; 이 한 줄이 바로 게임 전체를 얼려버리는 핵심 코드입니다. 이 코드가 실행되면 유니티 내부에서 다음과 같은 일들이 일어납니다.

  1. Time.deltaTime이 0이 됩니다:
    • Enemy.cs Update() 함수를 보면 moveSpeed * Time.deltaTime 코드가 있습니다.
    • Time.deltaTime은 이전 프레임과 현재 프레임 사이의 시간 간격인데, Time.timeScale이 0이 되면 이 값도 0이 됩니다.
    • 따라서 moveSpeed * 0  0이 되므로, 몬스터의 MoveTowards 함수는 더 이상 움직이지 않게 됩니다. 모든 시간 기반 움직임이 멈춥니다.
  2. 물리 엔진이 멈춥니다 (FixedUpdate):
    • 리지드바디(Rigidbody)를 사용한 모든 물리적인 움직임이나 충돌 계산이 일어나는 FixedUpdate() 함수가 더 이상 호출되지 않습니다.
  3. 애니메이션이 멈춥니다:
    • 몬스터의 걷는 애니메이션이나 다른 모든 애니메이션의 재생 속도 역시 Time.timeScale의 영향을 받으므로, 그 자리에서 즉시 얼어붙습니다.

그럼 isGamePaused는 왜 필요한가?

Time.timeScale이 모든 것을 멈춘다면, isGamePaused 변수는 왜 필요할까요? 바로 Time.timeScale이 멈추지 못하는 것들을 수동으로 막기 위해서입니다.

예를 들어,

  • 플레이어의 입력: Time.timeScale = 0 이어도 Update() 함수 자체는 계속 호출되므로, Input.GetKeyDown() 같은 코드는 여전히 작동합니다. 만약 게임이 멈춘 동안 플레이어가 공격 버튼을 누르는 것을 막고 싶다면, 공격 코드 맨 위에 if (isGamePaused) return; 을 넣어줘야 합니다.
  • 충돌 감지: OnTriggerEnter2D 같은 함수는 물리 엔진이 멈추면 발생하지 않지만, 만약의 경우를 대비해 isGamePaused로 한 번 더 막아주면 더 안전합니다.
  • 다른 스크립트의 상태 확인: 다른 스크립트들이 지금 게임이 멈춘 상태인지 아닌지를 Time.timeScale을 직접 확인하는 것보다 GameManager.Instance.isGamePaused 를 확인하는 것이 훨씬 더 명확하고 안전합니다.

최종 정리

구분 isGamePaused 변수 Time.timeScale 속성
역할 상태 관리 (State): 게임이 멈췄는지 아닌지 알려주는 '깃발' 행동 제어 (Action): 시간을 실제로 얼리거나 흐르게 하는 '스위치'
하는 일 그 자체로는 아무것도 멈추지 않음. if문에서 조건을 체크하는 용도로 사용. Time.deltaTime을 0으로 만들고, 물리 엔진과 애니메이션을 멈춤.
비유 전원 표시등 램프 💡 리모컨의 실제 전원 버튼 ⏯️

결론적으로, LevelUp 함수가 GameManager.PauseGame()을 호출하면, GameManager '일시정지 램프를 켜고, 리모컨의 일시정지 버튼을 누르는' 두 가지 일을 동시에 수행하여 게임을 완벽하게 멈추는 것입니다.

 

 

 

 

 

 

6

근데 유니티에서 새로운 게임오브젝트 만들어놔야하잖아
이렇게 스크립트만 만들어서는 안되잖아
어떤 새로운 게임오브젝트를 만들어야하고
연결은 어케해줘야

 

 

1단계: 관리자(Manager) 게임 오브젝트 준비

이들은 눈에 보이지 않지만, 우리 시스템의 '뇌' 역할을 하는 가장 중요한 오브젝트들입니다.

  1. @GameManager 확인:
    • Hierarchy(계층) 창에 @GameManager 라는 이름의 게임 오브젝트가 이미 있을 겁니다.
    • 이 오브젝트에 우리가 수정한 GameManager.cs 스크립트가 잘 붙어있는지 확인하세요.
  2. @RewardManager 생성 (새로운 두뇌):
    • Hierarchy 창에서 우클릭 > Create Empty를 선택하여 빈 게임 오브젝트를 하나 만듭니다.
    • 이름을 @RewardManager 로 변경합니다. (@를 붙이면 다른 관리자들과 함께 정렬되어 보기 좋습니다.)
    • @RewardManager 오브젝트에 우리가 만든 RewardManager.cs 스크립트 파일을 마우스로 끌어다 붙여줍니다.

2단계: 보상 데이터 에셋(Asset) 만들기 (가장 중요!)

이것이 바로 ScriptableObject의 마법입니다. 코드 바깥에 '보상 설계도' 파일을 만드는 과정입니다.

  1. 새 폴더 만들기:
    • Project(프로젝트) 창에서 Assets 폴더 안에 Data라는 새 폴더를 만들고, 그 안에 또 Rewards라는 폴더를 만듭니다. (정리 목적)
  2. 보상 데이터 파일 생성:
    • 방금 만든 Rewards 폴더 안에서 우클릭을 합니다.
    • 메뉴에서 Create > Rewards 라는 새로운 메뉴가 생긴 것을 볼 수 있습니다. (이것은 [CreateAssetMenu] 코드 덕분입니다.)
    • Rewards 메뉴 안에 있는 Health Upgrade 를 선택합니다.
    • 새로운 파일이 하나 생성됩니다. 파일 이름을 체력 20 증가 와 같이 알아보기 쉽게 지어줍니다.
  3. 보상 데이터 내용 채우기:
    • 방금 만든 체력 20 증가.asset 파일을 클릭합니다.
    • 인스펙터 창에 이 보상의 세부 정보를 입력하는 칸들이 나타납니다.
      • Reward Name: "최대 체력 증가"
      • Description: "최대 체력이 20만큼 영구적으로 증가합니다."
      • Icon: 준비된 아이콘 이미지를 드래그앤드롭합니다.
      • Amount: 20 이라고 입력합니다.
    • 게임에 필요한 모든 보상(속도 증가, 데미지 증가 등)에 대해 이 과정을 반복하여 여러 개의 .asset 파일을 만들어 둡니다.

3단계: 레벨업 UI 만들기

이제 플레이어에게 실제로 보여줄 UI 화면을 만듭니다.

  1. RewardPanel 만들기:
    • Canvas 오브젝트 안에서 우클릭 > UI > Panel을 선택하여 패널을 하나 만듭니다.
    • 이름을 RewardPanel 로 변경합니다. 이 패널은 평소에는 비활성화 상태로 둘 것입니다.
  2. 카드 컨테이너 만들기:
    • RewardPanel 안에서 우클릭 > Create Empty를 하여 빈 오브젝트를 만듭니다.
    • 이름을 CardContainer 로 변경합니다.
    • (강력 추천) CardContainer Horizontal Layout Group 컴포넌트를 추가하여 카드들이 자동으로 정렬되게 만듭니다. (Padding, Spacing 조절)
  3. RewardCard 프리팹 만들기 (재사용을 위해):
    • CardContainer 안에 임시로 단 하나의 카드를 디자인합니다.
    • UI > Button을 하나 만들고 이름을 RewardCard 로 짓습니다.
    • RewardCard 안에 UI > Image (아이콘용), UI > Text - TextMeshPro (이름, 설명용)를 자식으로 추가하여 보기 좋게 배치합니다.
    • 디자인이 완성된 RewardCard 오브젝트에 RewardCardUI.cs 스크립트를 붙여줍니다.
    • RewardCardUI 스크립트의 인스펙터에 있는 Name Text, Description Text, Icon Image, Select Button 필드에 방금 만든 UI 요소들을 각각 끌어다 연결합니다.
    • 완성된 RewardCard 오브젝트를 프로젝트 창으로 드래그하여 **프리팹(Prefab)**으로 만듭니다.
    • 프리팹을 만든 후에는 Hierarchy 창에 있던 원본 RewardCard는 삭제해도 됩니다.

4단계: 모든 것 연결하기 (최종 단계)

이제 1, 2, 3단계에서 만든 모든 것들을 '전선'으로 연결합니다.

  1. @RewardManager 연결:
    • Hierarchy 창에서 @RewardManager 오브젝트를 다시 선택합니다.
    • 인스펙터 창에 비어있는 필드들을 채워줍니다.
      • All Rewards 리스트:
        • + 버튼을 눌러 칸을 늘리고, 2단계에서 만든 모든 보상 데이터 .asset 파일들을 프로젝트 창에서 끌어다 이곳에 등록합니다.
      • Reward Panel:
        • Hierarchy 창에 있는 RewardPanel 오브젝트를 이곳에 끌어다 놓습니다.
      • Card Container:
        • Hierarchy 창에 있는 CardContainer 오브젝트를 이곳에 끌어다 놓습니다.
      • Reward Card Prefab:
        • 프로젝트 창에 있는 RewardCard.prefab 파일을 이곳에 끌어다 놓습니다.
  2. 다른 오브젝트 확인:
    • @GameManager: isGamePaused 변수가 추가된 것 외에 새로 연결할 것은 없습니다.
    • Player: PlayerStats.cs 스크립트가 잘 붙어있는지 확인합니다. 새로 연결할 것은 없습니다. (RewardManager.Instance로 알아서 찾아갑니다.)

 

 

 

 

Q

A

Rewards 폴더까지는 만들엇는데, 그다음에 뭘해야하는거지 ?

 

 

 

 

보상 데이터 파일 생성하기 (상세 가이드)

1. Rewards 폴더에서 우클릭하기

  • 스크린샷에 보이는 것처럼 Assets > Data > Rewards 폴더를 마우스로 클릭하여 선택된 상태로 만듭니다. (폴더가 파랗게 표시됩니다)
  • 이제 그 오른쪽, "This folder is empty" 라고 보이는 빈 공간 아무 곳에서나 마우스 오른쪽 버튼을 클릭합니다.

2. Create 메뉴 확인하기

  • 마우스 오른쪽 버튼을 누르면 긴 메뉴가 나타납니다.
  • 그 메뉴의 맨 위쪽에 있는 Create 라는 항목에 마우스를 가져다 댑니다.
  • 그러면 또 다른 하위 메뉴가 옆으로 펼쳐집니다.

3. 새로운 Rewards 메뉴 찾기 (핵심!)

  • Create의 하위 메뉴를 잘 살펴보세요.
  • C# Script, Folder, Material 같은 익숙한 항목들 사이에, 우리가 만든 Rewards 라는 새로운 카테고리가 생긴 것을 발견할 수 있습니다.
    • 이 메뉴는 RewardData.cs 스크립트 맨 위에 붙여준 [CreateAssetMenu(menuName = "Rewards/...")] 코드 덕분에 자동으로 생성된 것입니다.

4. 보상 종류 선택하기

  • Rewards 카테고리 위에 마우스를 올리면, 그 안에서 우리가 만들었던 구체적인 보상 클래스들을 선택할 수 있습니다.
    • 예를 들어, HealthUpgradeReward.cs 스크립트에 [CreateAssetMenu(menuName = "Rewards/Health Upgrade")] 라고 적었기 때문에, Rewards 메뉴 안에는 Health Upgrade 라는 항목이 보일 것입니다.

5. 파일 생성 및 이름 변경

  • Rewards > Health Upgrade  마우스 왼쪽 버튼으로 클릭합니다.
  • 그러면 Rewards 폴더 안에 New HealthUpgradeReward 라는 이름의 새로운 파일(에셋)이 하나 생성됩니다.
  • 이 파일의 이름이 파랗게 선택된 상태일 때, 키보드로 체력 20 증가 와 같이 알아보기 쉬운 이름으로 바로 변경해 줍니다. (만약 선택이 풀렸다면, 파일을 한 번 클릭하고 F2 키를 눌러 이름을 바꿀 수 있습니다.)

이것으로 '보상 데이터 설계도' 파일 하나가 완성된 것입니다!

이제 이 파일을 클릭하면, 인스펙터 창에 이름, 설명, 아이콘, 증가량(Amount) 등을 입력할 수 있는 필드들이 나타납니다.

필요한 모든 보상(속도 증가, 데미지 증가 등)에 대해 이 "우클릭 > Create > Rewards > (원하는 보상 선택)" 과정을 반복하여 여러 개의 .asset 파일을 만들어 주시면 됩니다.

 

 

 

 

 

 

A

스크린샷을 보니 정말로 Create 메뉴 안에 'Rewards' 라는 항목이 보이지 않네요. 아주 중요한 포인트를 발견하셨습니다. 이것은 사용자님이 무언가를 잘못한 것이 아니라, 유니티가 스크립트 코드를 정상적으로 인식하지 못했을 때 나타나는 매우 전형적인 현상입니다.

결론부터 말씀드리면, 스크립트 파일에 컴파일 에러가 있거나, 저장이 되지 않았을 가능성이 99%입니다.

[CreateAssetMenu] 속성은 코드가 성공적으로 컴파일되어야만 유니티 에디터의 메뉴에 나타납니다. 메뉴가 보이지 않는다는 것은, 유니티가 그 코드를 읽는 데 실패했다는 강력한 신호입니다.


해결을 위한 체크리스트 (가장 가능성이 높은 원인부터)

1. 콘솔(Console) 창의 에러 메시지 확인 (가장 중요!)

이것이 가장 먼저 확인해야 할 부분입니다.

  1. 유니티 에디터 하단에 있는 Console 탭을 클릭하여 콘솔 창을 엽니다. (만약 보이지 않는다면, 상단 메뉴의 Window > General > Console을 선택하세요.)
  2. 콘솔 창에 빨간색 아이콘(❗)과 함께 에러 메시지가 있는지 확인하세요.
  3. 아마도 RewardData.cs  HealthUpgradeReward.cs 파일과 관련된 에러 메시지가 있을 것입니다. 예를 들어 "세미콜론(;)이 없습니다", "중괄호({)가 맞지 않습니다" 와 같은 메시지입니다.
  4. 이 에러 메시지를 해결하지 않으면, 유니티는 절대 새로운 메뉴를 만들어주지 않습니다. 에러 메시지를

 

 

 

 

 

 

Q

근데 이해가 안되는게 게임오브젝트(혹은 프리팹 자체는 3개 한세트를 미리 만들어놓아야하는거 아닌가
나중에는 그 3개의 각각에 랜덤한 얘들이 들어갈거긴한데
적어도 위치정보도 있고 한데
ui를 하나만 만들면 안되는거 아닌가

 

 

A

결론: 왜 UI를 하나만 만들어도 되는가?

  • 위치 정보는 cardContainer가 담당: 개별 카드의 위치를 미리 3개 만들어 둘 필요가 없습니다. 카드가 생성될 부모(cardContainer)의 위치와 **자동 정렬 컴포넌트(Horizontal Layout Group)**가 모든 위치 문제를 해결해 줍니다. 우리는 그냥 컨테이너 안에 카드를 던져 넣기만 하면 됩니다.
  • 내용물은 코드가 실시간으로 채워줌: 카드의 모습은 똑같은 틀(프리팹)을 사용하지만, 그 안의 내용물(이름, 설명, 아이콘)은 Instantiate 직후에 코드를 통해 각각 다른 데이터로 채워지기 때문에 결과적으로는 서로 다른 3개의 카드처럼 보이게 됩니다.

 

 

 

 

 

 

 

 

Q

RewardCard 오브젝트에 RewardCardUI.cs 스크립트를 붙여줍니다.

근데 그러면 그 부모인 
reward panel이랑
cardContainer은 왜 만든거지 ?

현재 게임오브젝트 구조를 보면

 

A

 현재 Hierarchy 창에 보이는 구조는 최종 게임의 모습이 아니라, 재사용 가능한 부품(프리팹, Prefab)을 만들기 위한 '임시 작업대' 라고 생각하시면 완벽하게 이해됩니다.

현재 보이는 RewardCard는 '견본품'이고, RewardPanel CardContainer는 그 견본품으로 찍어낼 카드들을 담을 '그릇'과 '정리함'입니다.

각 오브젝트의 역할을 하나씩 설명해 드리겠습니다.

1. RewardPanel의 역할: 전체 팝업창 (The Stage)

  • 목적: 레벨업 보상 선택에 관련된 모든 UI 요소들을 하나로 묶어주는 최상위 부모입니다.
  • 기능:
    • 그룹화: 배경 이미지, "보상을 선택하세요!" 같은 제목 텍스트, 그리고 카드들이 담길 CardContainer까지, 이 모든 것을 한 그룹으로 관리합니다.
    • 보여주기/숨기기: 가장 중요한 역할입니다. 우리는 카드 3개를 각각 켜고 끄는 게 아니라, RewardPanel 하나만 켜고 끄면 됩니다.
      • 레벨업 시: rewardPanel.SetActive(true); -> 보상 선택창 전체가 화면에 나타납니다.
      • 보상 선택 후: rewardPanel.SetActive(false); -> 보상 선택창 전체가 화면에서 사라집니다.
    • 비유: 연극 무대와 같습니다. 배우(카드)들이 올라갈 전체 무대 세트입니다.

2. CardContainer의 역할: 자동 정렬기 (The Organizer)

  • 목적: 코드를 통해 동적으로 생성될 3개의 RewardCard 실제로 놓일 위치입니다.
  • 기능:
    • 부모 역할: Instantiate로 생성된 카드들이 이 오브젝트의 자식으로 들어가게 됩니다.
    • 자동 레이아웃 (핵심 기능): 여기에 Horizontal Layout Group 컴포넌트를 추가하면, 자식으로 들어오는 오브젝트(카드)들을 알아서 가로로 나란히, 예쁜 간격으로 정렬해 줍니다. 우리는 카드 1, 2, 3의 위치를 일일이 계산할 필요가 전혀 없습니다. 그냥 CardContainer 안에 생성하기만 하면 됩니다.
    • 비유: 사진을 나란히 꽂아두는 앨범의 한 페이지나, 책장의 한 칸과 같습니다.

3. RewardCard의 역할: 견본품 및 프리팹 재료 (The Blueprint)

  • 현재 상태 (Hierarchy 창에 있는 이유):
    • 지금 Hierarchy에 보이는 RewardCard는 **프리팹으로 만들기 위한 단 하나의 '견본품' 또는 '원본'**입니다.
    • 여기서 개발자는 카드 한 장이 어떻게 생겼는지 (이미지 위치, 텍스트 폰트, 버튼 모양 등) 눈으로 보면서 디자인합니다.
    • RewardCardUI.cs 스크립트를 붙이고, 인스펙터 창에서 Name Text, Icon Image 등을 연결하는 작업을 바로 이 '견본품'으로 하는 것입니다.
  • 다음 단계 (가장 중요):
    1.  RewardCard의 디자인과 스크립트 연결이 완벽하게 끝나면, Hierarchy 창에서 이 RewardCard 오브젝트를 프로젝트(Project) 창으로 드래그 앤 드롭합니다.
    2. 이 행동을 하는 순간, 재사용 가능한 **RewardCard 프리팹(붕어빵 틀)**이 만들어집니다.
    3. 프리팹을 만들고 나면, Hierarchy 창에 있던 '견본품' RewardCard는 삭제합니다.

결과적으로 Hierarchy는 RewardPanel과 **비어있는 CardContainer**만 남게 됩니다.

최종 워크플로우 정리

  1. 설계 (지금 하시는 단계):
    • RewardPanel(무대)과 CardContainer(정리함)를 만든다.
    • CardContainer 안에 RewardCard(견본품)를 만들어 완벽하게 디자인한다.
    • 완성된 RewardCard를 프로젝트 창으로 옮겨 프리팹을 만든다.
    • Hierarchy에 있던 RewardCard 견본품을 삭제한다.
  2. 실행 (게임 중 레벨업 시):
    • RewardManager 스크립트가 RewardCard **프리팹(붕어빵 틀)**을 가져온다.
    • Instantiate를 사용해 프리팹의 복사본 3개를 만든다.
    • 이 복사본들을 비어있던 CardContainer(정리함) 안에 자식으로 넣어준다.
    • CardContainer Horizontal Layout Group이 3개의 카드를 자동으로 정렬한다.
    • RewardPanel(무대) 전체를 활성화해서 플레이어에게 보여준다.

따라서 RewardPanel CardContainer는 동적으로 생성될 카드들을 담고, 보여주고, 정렬하기 위한 필수적인 '그릇'과 '틀'이라고 할 수 있습니다.

 

 

 

 

 

 

 

 

Q

지금, text ui를 더 만들어야하는건가?
또한 select button ui는 하이아키에 없는것같은데

 

A

  1. 텍스트 추가 및 이름 변경:
    • RewardCard 자식으로 Text - TextMeshPro를 하나 더 만듭니다.
    • 두 개의 텍스트 오브젝트 이름을 각각 NameText, DescriptionText로 바꿉니다.
  2. 인스펙터 연결:
    • RewardCard를 클릭합니다.
    • (버튼) Hierarchy의 **RewardCard**를 -> Inspector의 Select Button 필드로 드래그
    • (이름) Hierarchy의 **NameText**를 -> Inspector의 Name Text 필드로 드래그
    • (설명) Hierarchy의 **DescriptionText**를 -> Inspector의 Description Text 필드로 드래그
    • (아이콘) Hierarchy의 **Image**를 -> Inspector의 Icon Image 필드로 드래그

 

 

 

 

Q

이 rewardcard를 prefab으로 이동?

 

 

 

 

Q

근데 프리팹으로는 만들어놨는데
그냥 prefab으로 있다고 해서, 자동으로 불러지는건 아니잖아
뭔가 연결 필요없나

 

근데 reward panel
card container도 연결시켜줘야하나

 

 

A

왜 연결해야 할까요?

RewardManager 스크립트는 일종의 '지배인'과 같습니다. 이 지배인이 일을 하려면 자신의 '작업 공간'이 어디인지 알아야 합니다.

  • Reward Panel을 연결해야 하는 이유:
    • RewardManager 스크립트는 ShowRewardScreen() 함수에서 rewardPanel.SetActive(true); 코드를 통해 보상 창 전체를 화면에 보여줘야 합니다.
    • SelectReward() 함수에서는 rewardPanel.SetActive(false); 코드로 보상 창을 다시 숨겨야 합니다.
    • 이 필드를 연결해주지 않으면, 스크립트는 어떤 패널을 켜고 꺼야 할지 전혀 알 수 없습니다.
  • Card Container를 연결해야 하는 이유:
    • ShowRewardScreen() 함수에서 Instantiate(rewardCardPrefab, cardContainer); 코드를 통해 새로 만든 카드를 어디에 배치할지 알려줘야 합니다.
    • 또한 foreach (Transform child in cardContainer) 코드를 통해 이전에 있던 카드들을 삭제하려면, 어떤 컨테이너를 청소해야 하는지 알아야 합니다.
    • 이 필드를 연결해주지 않으면, 스크립트는 카드를 어디에 만들어야 할지, 어디를 청소해야 할지 몰라 헤매게 됩니다.

 

 

 

 

 

 

 

 

 

 

 

 

현재 구조는 새로운 강화 선택지를 추가하기에 매우 이상적으로 설계되어 있습니다. 놀랍게도, 새로운 C# 스크립트 파일은 단 하나도 만들 필요가 없습니다.

기존의 HealthUpgradeReward.cs는 이미 "최대 체력을 특정 amount만큼 올린다"는 기능을 완벽하게 수행하고 있기 때문입니다. 우리는 이 '틀'을 사용해서 내용물만 다른 데이터 파일을 여러 개 만들어주기만 하면 됩니다.

핵심 개념: 코드(틀)와 데이터(내용물)의 분리

  • HealthUpgradeReward.cs (틀): "체력 붕어빵 틀"입니다. 이 틀은 '팥'(amount)을 얼마나 넣을지 정할 수 있습니다.
  • .asset 파일 (내용물): 이 틀로 찍어낼 실제 "붕어빵"입니다. 우리는 "팥 5g짜리 붕어빵", "팥 10g짜리 붕어빵", "팥 15g짜리 붕어빵"을 각각 만들기만 하면 됩니다.

새로운 게임 오브젝트도 전혀 필요 없습니다. RewardData ScriptableObject이므로, Hierarchy 창이 아닌 Project 창에서 데이터 에셋 파일로 관리됩니다.


단계별 해결 방법

1단계: 코드 확인 (수정할 필요 없음)

요청하신 대로 전체 코드를 다시 보여드리지만, 실제로는 수정할 내용이 전혀 없습니다. 기존 코드가 이미 완벽하게 재사용 가능하도록 만들어져 있습니다.

// 파일 이름: RewardData.cs (변경 없음)

Generated csharp

using UnityEngine;

// 이 클래스를 직접 사용하기보다 상속해서 사용하므로, 
// 메뉴 경로는 자식 클래스에서 정의하는 것이 더 명확할 수 있습니다.
public abstract class RewardData : ScriptableObject
{
    public string rewardName;
    [TextArea(3, 5)] // 인스펙터에서 여러 줄로 설명을 편하게 쓰기 위함 (3~5줄 높이)
    public string description;
    public Sprite icon;

    // 핵심: 모든 자식 보상들이 반드시 구현해야 하는 '적용' 함수
    public abstract void Apply(PlayerStats playerStats);
}
 
Use code with caution.C#

// 파일 이름: HealthUpgradeReward.cs (변경 없음)

Generated csharp

using UnityEngine;

[CreateAssetMenu(fileName = "NewHealthUpgrade", menuName = "Rewards/Health Upgrade")]
public class HealthUpgradeReward : RewardData
{
    [Tooltip("체력을 얼마나 증가시킬지 설정합니다.")]
    public int amount; // 이 값을 바꿔가며 여러 종류의 보상을 만들 수 있습니다.

    // RewardData의 추상 함수를 오버라이드하여 구체적인 로직을 구현
    public override void Apply(PlayerStats playerStats)
    {
        if (playerStats != null)
        {
            playerStats.maxHealth += amount;
            // 보너스: 증가한 만큼 현재 체력도 회복시켜주는 것이 일반적입니다.
            playerStats.Heal(amount); 
        }
    }
}
 
Use code with caution.C#

(참고: PlayerStats에 Heal(int value) 같은 함수가 있다면 더 좋습니다. 없다면 playerStats.currentHealth += amount;로 처리하고, 최대 체력을 넘지 않도록 로직을 추가해야 합니다.)

// 파일 이름: RewardManager.cs (변경 없음)
(제공해주신 코드와 동일하므로 생략합니다. 수정할 필요가 없습니다.)


2단계: 새로운 '보상 데이터' 에셋 만들기 (가장 중요!)

이제 "최대 체력 +5, +10, +15"에 해당하는 실제 데이터 파일을 만듭니다.

  1. Unity 에디터의 Project(프로젝트) 창으로 갑니다.
  2. 보상 데이터를 모아둘 폴더(예: Assets/Data/Rewards)에서 마우스 우클릭을 합니다.
  3. 메뉴에서 Create > Rewards > Health Upgrade를 선택합니다.
  4. 새로 생성된 NewHealthUpgrade.asset 파일의 이름을 HealthUpgrade_5 와 같이 알아보기 쉽게 변경합니다.
  5. 이 과정을 두 번 더 반복하여 HealthUpgrade_10, HealthUpgrade_15 파일을 만듭니다.

이제 3개의 새로운 .asset 파일이 생겼습니다. 각각을 설정해 줍시다.

  • HealthUpgrade_5 파일을 클릭하고 인스펙터 창에서 설정:
    • Reward Name: 최대 체력 증가 (소)
    • Description: 최대 체력이 5만큼 증가합니다.
    • Icon: (원하는 아이콘 이미지 연결)
    • Amount: 5
  • HealthUpgrade_10 파일을 클릭하고 인스펙터 창에서 설정:
    • Reward Name: 최대 체력 증가 (중)
    • Description: 최대 체력이 10만큼 증가합니다.
    • Icon: (원하는 아이콘 이미지 연결)
    • Amount: 10
  • HealthUpgrade_15 파일을 클릭하고 인스펙터 창에서 설정:
    • Reward Name: 최대 체력 증가 (대)
    • Description: 최대 체력이 15만큼 증가합니다.
    • Icon: (원하는 아이콘 이미지 연결)
    • Amount: 15

이제 기존의 HealthUpgrade_20을 포함하여 총 4개의 체력 강화 데이터가 준비되었습니다.


3단계: 새로운 보상을 RewardManager에 연결하기

마지막으로, RewardManager에게 "이제 이런 보상들도 선택지에 포함시켜!"라고 알려줘야 합니다.

  1. Hierarchy(계층) 창에서 @RewardManager 게임 오브젝트를 클릭합니다.
  2. 인스펙터 창에서 Reward Manager (Script) 컴포넌트를 찾습니다.
  3. Data 섹션 아래의 All Rewards 리스트를 봅니다.
  4. 리스트의 Size를 늘려서 (예: 1에서 4로) 새로운 칸들을 만듭니다.
  5. 프로젝트 창에서 방금 만든 HealthUpgrade_5, HealthUpgrade_10, HealthUpgrade_15 에셋 파일들을 마우스로 끌어다가 All Rewards 리스트의 비어있는 칸에 각각 넣어줍니다.

이제 RewardManager allRewards 리스트에는 4개의 체력 강화 보상이 모두 포함되었습니다. 게임을 실행하고 레벨업하면, 이 4개 중에서 랜덤으로 3개가 선택지에 나타날 것입니다.

이처럼 ScriptableObject를 사용하면, 코드는 전혀 수정하지 않고 데이터 에셋 파일만 추가/관리하는 것으로 게임의 콘텐츠를 매우 쉽고 유연하게 확장할 수 있습니다.

 

 

나중에, 

각 보상별 등급도 만들려면, 리스트도 추가해야할것같기도 한데,

이건 나중에 생각해보자

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Q

화면의 중간에 뜨게 하고 싶은데

뭘 수정해야하지

 

 

 

근데 지금 화면의 중앙에 있는거 맞지않나

 

 

 

문제의 원인은 RewardPanel의 위치가 화면 중앙으로 설정되어 있지 않기 때문입니다.

UI 요소들은 항상 부모를 기준으로 자신의 위치를 정합니다. 이 경우, 보상 카드들은 CardContainer 안에, CardContainer RewardPanel 안에, 그리고 RewardPanel Canvas 안에 있습니다. 따라서 우리가 RewardPanel을 화면 정중앙에 배치하면, 그 안에 있는 모든 내용물(카드들)도 중앙을 기준으로 정렬됩니다.

핵심 해결 방법: RewardPanel의 앵커(Anchor) 및 위치 조정

가장 확실하고 쉬운 방법은 유니티의 앵커 프리셋(Anchor Presets) 기능을 사용하는 것입니다.

  1. RewardPanel 선택: Hierarchy(계층) 창에서 RewardPanel 게임 오브젝트를 클릭합니다.
  2. Rect Transform 찾기: Inspector(인스펙터) 창의 맨 위에 있는 Rect Transform 컴포넌트를 찾습니다.
  3. 앵커 프리셋 열기: Rect Transform 컴포넌트의 왼쪽 위에 있는 사각형 모양의 아이콘을 클릭합니다. 다양한 사각형 모양이 그려진 격자무늬 창이 나타납니다.
  4. 중앙 정렬 (가장 중요!):
    • 키보드의 Alt 키와 Shift 키를 동시에 누른 상태에서,
    • 격자무늬 창의 **정중앙에 있는 사각형(middle-center)**을 클릭합니다.
    • Shift 키의 역할: 오브젝트의 기준점(Pivot)을 중앙으로 설정합니다.
    • ``Alt` 키의 역할: 오브젝트의 위치(Position)를 앵커가 있는 곳(중앙)으로 이동시킵니다.
    • 이 두 키를 함께 누르고 클릭하면, 앵커, 피봇, 위치가 모두 한 번에 중앙으로 완벽하게 정렬됩니다.
  5. 확인: 이 작업을 마치면 인스펙터 창에서 RewardPanel Pos X Pos Y 값이 0으로 바뀐 것을 확인할 수 있습니다. 이제 게임을 실행하면 RewardPanel이 화면 정중앙에 나타날 것입니다.

추가 확인 사항

만약 위와 같이 설정했는데도 카드들의 위치가 미세하게 이상하다면, RewardPanel의 자식인 **CardContainer**도 같은 방법으로 중앙에 정렬되어 있는지 확인해 보세요.

  1. Hierarchy 창에서 CardContainer를 선택합니다.
  2. 위와 동일한 방법으로 Alt + Shift 키를 누른 채 앵커 프리셋의 정중앙을 클릭하여 CardContainer RewardPanel의 중앙에 위치하도록 합니다.

이 과정을 통해 UI가 의도한 대로 화면 중앙에 깔끔하게 표시될 것입니다.

 

 

 

네, 화면을 보니 왜 그렇게 생각하시는지 충분히 이해가 갑니다. 현재 씬(Scene) 뷰에서는 오브젝트가 중앙에 있는 것처럼 보입니다.

하지만 여기서 유니티 UI의 가장 중요하고 헷갈리기 쉬운 개념인 **앵커(Anchor)**와 **피봇(Pivot)**의 차이를 아셔야 합니다.

결론부터 말씀드리면, 현재 중앙에 있는 것은 맞지만, 그 기준이 "화면 중앙"이 아니라 "화면 왼쪽 아래 구석"으로 되어 있는 상태입니다.

무엇이 문제인가요? (앵커의 역할)

제공해주신 이미지의 왼쪽 아래 구석을 자세히 보면, 네 개의 작은 삼각형 모양(앵커)이 한 점에 모여있는 것을 볼 수 있습니다. (이미지에서는 빨간색 X 표시로 강조된 부분입니다.)

  • 앵커(Anchor)란? UI 요소가 부모(여기서는 Canvas)의 어느 부분에 자신을 '고정'시킬지 정하는 기준점입니다. 닻(anchor)과 같은 역할입니다.
  • 현재 상태: 앵커가 화면 왼쪽 아래에 모두 모여 있습니다.
  • 이것의 의미: "이 RewardPanel의 위치를 화면(Canvas)의 왼쪽 아래를 기준으로 계산하라"는 의미입니다.

따라서 인스펙터 창에서 위치(Pos X, Pos Y)를 (0, 0)으로 설정하면, RewardPanel의 피봇(파란색 원)이 화면 왼쪽 아래 구석에 가서 붙게 됩니다. 지금 씬 뷰에서 중앙처럼 보이는 것은, 단순히 씬 뷰 카메라가 그 영역을 중앙으로 비추고 있기 때문입니다.

무엇을 봐야 할까요? (피봇과 앵커의 차이)

  • 파란색 원 (피봇, Pivot): 이것은 RewardPanel 자기 자신의 중심점입니다. 크기를 조절하거나 회전할 때 이 점을 기준으로 움직입니다. 현재 RewardPanel의 피봇은 자기 자신의 중앙(0.5, 0.5)으로 잘 설정되어 있습니다.
  • 작은 삼각형 4개 (앵커, Anchors): 이것이 부모(화면)와의 관계를 결정합니다. 진짜 위치를 결정하는 핵심 요소입니다.

지금 상황을 비유하자면,
"내(RewardPanel) 몸의 중심(피봇)은 배꼽에 있다. 이제 내 배꼽을 교실(Canvas)의 왼쪽 아래 구석(앵커)에 갖다 대라." 라는 명령이 실행된 것과 같습니다. 결과적으로 나는 교실 왼쪽 아래 구석에 서 있게 됩니다.

해결 방법 (다시 한번!)

이 문제를 해결하고 "내 배꼽을 교실의 정중앙에 갖다 대라"로 명령을 바꾸려면, 이전 답변에서 설명드린 앵커 프리셋을 사용해야 합니다.

  1. Hierarchy에서 RewardPanel을 선택합니다.
  2. 인스펙터의 Rect Transform 컴포넌트에서 앵커 프리셋 아이콘(사각형 모양)을 클릭합니다.
  3. Alt + Shift 키를 누른 상태로 정중앙에 있는 사각형을 클릭합니다.

이 작업을 마치면, 씬 뷰에서 네 개의 앵커 삼각형들이 더 이상 왼쪽 아래 구석이 아닌, RewardPanel 사각형의 네 꼭짓점에 각각 붙어 중앙으로 펼쳐진 모습을 볼 수 있습니다.

이제 RewardPanel은 자신의 위치를 화면(Canvas)의 정중앙을 기준으로 계산하게 되며, Pos X, Pos Y가 (0, 0)일 때 비로소 게임 화면의 정중앙에 나타나게 됩니다.

 

 

 

 

 

 

 

 

 

 

Q

The character with Unicode value \uB2E4 was not found in the [LiberationSans SDF] font asset or any potential fallbacks. It was replaced by Unicode character \u25A1 in text object [DescriptionText].
UnityEngine.Debug:LogWarning (object,UnityEngine.Object)
TMPro.TextMeshProUGUI:SetArraySizes (TMPro.TMP_Text/UnicodeChar[]) (at ./Library/PackageCache/com.unity.textmeshpro@3.0.7/Scripts/Runtime/TMPro_UGUI_Private.cs:1274)
TMPro.TMP_Text:ParseInputText () (at ./Library/PackageCache/com.unity.textmeshpro@3.0.7/Scripts/Runtime/TMP_Text.cs:1902)
TMPro.TextMeshProUGUI:OnPreRenderCanvas () (at ./Library/PackageCache/com.unity.textmeshpro@3.0.7/Scripts/Runtime/TMPro_UGUI_Private.cs:1644)
TMPro.TextMeshProUGUI:Rebuild (UnityEngine.UI.CanvasUpdate) (at ./Library/PackageCache/com.unity.textmeshpro@3.0.7/Scripts/Runtime/TextMeshProUGUI.cs:216)
UnityEngine.Canvas:SendWillRenderCanvases ()

한글로된 설명을 적어서 그런