정신과 시간의 방

전체 글 273

카테고리 설명
게임 프로그래머가 되기 위한 청춘의 기록
  • C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 객체지향 파트에서 배우고 느낀 점을 정리해보았다. 절차지향과 객체 지향의 비교 - 절차지향 (Procedural) : 모든 기능을 함수 기반으로 만들고, 함수끼리 조립하여 프로그래밍하는 형식. 심플하고 직관적이라는 장점이 있다. 동작시 순차적으로 실행되기 때문에 간단한 프로그램이나 작은 규모의 툴인 경우 간단하게 만들 수 있다. 하지만 순차 실행되는 성질 때문에 로직을 수정 / 추가하게되면 전체적인 코드 흐름에 맞추어 수정해야 하기 때문에 무척 까다롭다. 특히 기능 추가 시 계속해서 유사한 함수를 만들어야 한다. 즉, 프로그램의 규모가 커질수록 유지보수가 힘들다는 큰 단점이 있다. 객체지향 (Object oriented) - 객체(=코..

  • C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 VS에서의 디버깅에 대해 정리해본다. { // 메소드 호출 -> inception // 현실 -> 1차 꿈 -> 2차꿈 class Program { static void Print(int value) { Console.WriteLine(value); } static int AddAndPrint(int a, int b) { int ret = a + b; Print(ret); return ret; } static void Main(string[] args) // 메인을 현실이라고 가정했을 때, // F10을 누르면 현실에서 움직임 // F11을 누르면 꿈(함수) 안으로 들어감 { Program.AddAndPrint(5, 15); Progr..

    TIL

    220802 디버깅 NEW

    2022.08.02
    댓글
  • C#과 유니티로 만드는 MMORPG 게임 개발 시리즈를 수강하며 연습문제를 풀어보았다. class Program { // static void Main(string[] args) { for (int i = 2; i

  • C#과 유니티로 만드는 MMORPG 게임 개발 시리즈를 수강하며 개인적으로 정리가 필요하다 생각한 기초 문법을 다시 살펴보았다. 1. 데이터 연산 - 전위/후위 증감 연산자 전위/후위 연산자 중 어느 쪽을 사용해도 연산에 의한 최종 변수 값은 같게 나온다. 다만 둘은 작동 순서에 차이가 있다. 전위 증가 연산자 - 함수(WriteLine)이 실행되기 전에 증가 연산자가 먼저 작동한다. 따라서 함수에 의한 출력 값과 변수 값이 모두 같다. class Program { static void Main(string[] args) { // int hp = 100; Console.WriteLine(++hp); // hp의 값은 101, 출력 값도 101이 됨 } } 후위 증가 연산자 - 함수(WriteLine)이 ..

    C#

    [C#] 문법 정리 NEW

    2022.07.17
    댓글
  • C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 람다식에 대해 배웠다. 1. 람다식을 사용하지 않았을 때 enum ItemType { Weapon, Armor, Amulet, Ring } enum Rarity { Normal, Uncommon, Rare } class Item { public ItemType ItemType; public Rarity Rarity; } static List _items = new List(); // 가상의 인벤토리처럼 아이템 목록 역할을 하는 리스트 // 인벤토리에서 특정 아이템을 찾고 싶을 때 함수 구현(Find 함수를 하나씩 만든 Bad Ver.) static Item FindWeapon() { // 아이템을 쭉 스캔하면서 찾으려는 무기가 있는지 ..

    C#

    220716 C# 람다식 NEW

    2022.07.16
    댓글
  • · 오늘 공부한 내용 오늘은 어제 정리한 내용들을 바탕으로 설계를 수정하기 위해서 코드를 리뷰해보았다. 우선적으로 수정해야 할 것으로 보이는 부분이 크게 2가지가 있었는데, Task를 등록하는 과정과 블랙보드 참조 및 사용에 대한 부분이다. 1. Task 등록 부분 현재 구현된 내용상 모든 노드들은 공통적으로 base node를 상속받고, 크게 평가 함수 부분의 내용만 달라지도록 구현이 되어있다. 그런데 코드 리뷰를 하다보니 여기서 문제점이라고 할까, 눈에 밟히는 부분이 있었다. 리프 노드(Task)들의 수가 많아지면 상속을 받는 클래스들이 불필요하게 너무 많아진다는 점이다. 생각을 정리해 본 결과 이 기능을 구현하자면 크게 2가지 방법으로 처리할 수 있을 것 같다. 클래스 상속으로 task 정의(복잡한..

  • 정보 전달성 포스팅이라기 보다는 TIL 기록 겸 설계를 위한 생각 정리용 포스팅이 되겠다. 현재 유니티에서 BT를 구현하고 있는데, 프로토타입 기본 기능까지는 완성했으나 응용에 여러 어려움이 있어 오히려 구현 과정이 복잡해지는 느낌이다. 특히 변수 처리. 트리를 만들 때마다 정의한 task 내부에서 변수를 처리하고 있는데 이 부분이 비효율적이라 석연치 않다. 근본 문제가 무엇인가 고민해보니 기능 구현에 급급해 일반화를 하지 않아서 라는 결론이 나왔다. 따라서 우선 목표를 다음과 같이 잡고 프레임워크를 수정하려 한다. 개발 목표 - 유니티로 구현하지만 Base node들은 최대한 엔진에 종속적이지 않게 설계할 것 - 혼자 구현하고 활용하는데 그치지 않고, 타인도 사용하는 코드라고 생각하며 확장성과 일반화를..

  • Actor - 월드에 배치 또는 스폰할 수 있는 오브젝트 - 언리얼에서의 콘텐츠 구성 최소 단위 - 이름, 유형(클래스 명), 트랜스폼, 프로퍼티(액터에 설정된 속성 값), 게임 로직(특정 상황에 대한 구체적인 대응 행동 명령)으로 구성되어 있음 - 생성 직후 기본적으로 아무런 기능이 없음 - AI 컨트롤러 설정 불가 - 건물 등 배경 오브젝트 등이 여기에 해당 Pawn - 플레이어가 조종할 수 있는 엑터 - 플레이어 또는 AI가 빙의할 수 있는 모든 액터의 기본 클래스로 컨트롤러에서 입력을 받을 수 있는 액터 - 레벨에 존재하는 플레이어와 생물의 물리적 표현으로, 게임 세계에 실제로 보여지고 레벨과 물리적인 충돌을 하며 배치된 액터와 상호작용 함 - 탈 것, 인간형 폰 등이 여기에 해당 Characte..

카테고리
작성일
2022. 8. 5. 12:24
작성자
risehyun

C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 객체지향 파트에서 배우고 느낀 점을 정리해보았다.

 


 

  • 절차지향과 객체 지향의 비교
    - 절차지향 (Procedural)
     : 모든 기능을 함수 기반으로 만들고, 함수끼리 조립하여 프로그래밍하는 형식. 심플하고 직관적이라는 장점이 있다. 동작시 순차적으로 실행되기 때문에 간단한 프로그램이나 작은 규모의 툴인 경우 간단하게 만들 수 있다. 
     하지만 순차 실행되는 성질 때문에 로직을 수정 / 추가하게되면 전체적인 코드 흐름에 맞추어 수정해야 하기 때문에 무척 까다롭다. 특히 기능 추가 시 계속해서 유사한 함수를 만들어야 한다. 즉, 프로그램의 규모가 커질수록 유지보수가 힘들다는 큰 단점이 있다. 

  • 객체지향 (Object oriented)
    - 객체(=코드 내의 모든 것들, ex. 플레이어나 몬스터, NPC, 화살, 스킬...)를 중심으로 프로그램을 만드는 것.
    - 모든 객체는 묘사(Class화)가 가능하며, 묘사의 구성은 속성(=데이터 ex. hp, attack(공격력), pos(현재위치)) 그리고 기능(=함수, ex. Die, Attack(공격기능), Move)으로 나눌 수 있다.

  • Class
    - 일종의 붕어빵틀, 또는 설계도라고 할 수 있다. 알맞은 재료만 넣어주면(변수 할당 등) 미리 선언해둔 대로 객체가 생성된다.
    - 클래스로 선언한 객체는 실사용시 new 키워드로 객체를 생성해주어야 사용이 가능하다.
    class Knight
    {
        public int hp;
        public int attack;

        public void Move()
        {
            Console.WriteLine("Knight Move");
        }

        public void Attack()
        {
            Console.WriteLine("Knight Attack");
        }

    
    }

    class Program
    {
        static void Main(string[] args)
        {
            Knight knight = new Knight(); // new 키워드를 쓰지 않으면 해당 객체는 존재하지 않기 때문에 오류가 발생한다.
            knight.hp = 100;
            knight.attack = 10;

            knight.Move();
            knight.Attack();
        }
    }

 

  • 복사와 참조(얕은 복사와 깊은 복사)
    - 구조체(struct)인 Mage와 클래스(Class)인 Knight의 hp를 각각 0으로 만드는 함수를 실행해보고 둘의 차이점을 알아보았다.

    - 아래의 예제를 통해 Struct의 경우 복사본(얕은 복사)으로 연산되고, Class의 경우 ref(참조, 깊은 복사)로 작동됨을 알 수 있다. 결론적으로, Struct의 연산 결과는 실제 원본 값에 영향을 끼치지 않는다. 

Struct로 선언된 Mage의 hp를 0으로 할당하는 함수를 실행했음에도 여전히 hp가 초기값인 100이다.
반면 Class로 선언된 Knight의 경우 함수를 실행한 뒤 hp가 초기값인 100에서 0으로 변화하였다.

 

    - 같은 구조체를 사용하여 여러개의 객체를 만들더라도 두 객체는 서로 다른 것으로 취급되며 역시 원본 값에 영향을 끼치지 않고 복사본으로 연산을 수행하게 된다. 아래의 예제는 함수를 거치지 않고 객체의 변숫값에 변화를 준 것이다.

새롭게 생성된 두 객체에 각각 첫 번째 생성된 객체를 할당하고 hp 변수의 값을 변화시켰다.

    - struct로 구성된 mage2의 경우 첫번째 mage의 hp가 100인 것과 달리 hp가 0이 되었다. 이로써 둘은 별도의 객체처럼 취급됨을 알 수 있다. 반면 class로 구성된 knight2는 첫 번째 knight와 동일하게 hp가 0이 되었다. 즉 동일 클래스가 할당되어 있고, 클래스는 ref로 연산되기 때문에 둘은 서로 같은 객체를 가리키고 있다고 할 수 있다.

    - 별도의 객체를 만들고 싶다면 위의 예제처럼 이미 생성된 객체를 할당하는 것이 아니라 new 키워드를 사용해 설계도가 동일한 다른 객체를 생성해서 사용해야한다.

new 키워드로 선언된 knight 객체와 knight2객체는 서로 별개의 객체가 된다.

  • 깊은 복사에 대해 알기 위해 knight 클래스의 코드를 수정했다.
    class Knight
    {
        public int hp;
        public int attack;
        public Knight Clone() // 클론함수를 호출하면 새로운 knight를 만들어 준다음, 자신이 가진 hp값을 넣어주고 리턴함.
                              // 진퉁도, 짝퉁도 아닌 별개의 새로운 객체를 생성하는 것.
        {
            Knight knight = new Knight(); // new 키워드로 새롭게 생성했으므로
                                           // 기존에 생성된 knight와는 별개의 객체가 된다.
            knight.hp = hp;
            knight.attack = attack;
            return knight;
        }

        public void Move()
        {
            Console.WriteLine("Knight Move");
        }

        public void Attack()
        {
            Console.WriteLine("Knight Attack");
        }

    }

    - 추가된 Clone함수를 이용해서 객체를 생성하면 knight와 동일한 hp값이 할당된 이전의 knight2와 달리 완전하게 분리된 별도의 객체로 생성되었기 때문에 hp의 값이 서로 다르게 할당됨을 알 수 있다.

 

  • 스택과 힙
    - 데이터 저장 시 사용되는 스택과 힙 메모리가 있다.

    - 스택은 불안정하고, 임시적으로 사용할 메모리라고 할 수 있다. ex. 일종의 함수를 실행하기 위한 메모장. 함수 안에 선언되는 변수들은 스택 메모리로 들어간다.

    - 스택을 복사 영역과 참조 영역으로 나누어 보면, 복사에는 본체가 들어간다. ex. mage의 hp와 attack에 해당하는 크기만큼(int형 2개 이므로 8바이트) 메모리 사이즈가 할당된다.

    - 참조 영역은 본체가 들어가는 것이 아니라, 주소가 들어간다. 주소는 64bit에서는 64, 32bit 에서는 32bit 이다.

    - 주소 안에는 실제 본체가 있는 메모리의 주소를 나타낸다. 참조 타입의 본체는 힙 영역에 들어간다.

    - 힙 영역은 동적으로 할당되는 것, 실시간으로 할당되는 것들을 취급한다.

    - 즉, 스택 안의 main 함수 안을 들여다보면 구조체로 선언한 mage의 경우 스택 안에 메모리가 할당(각각의 진퉁)이되지만, class인 knight는 첫 knight의 경우 new 키워드를 사용하여 생성하였으므로 힙 영역의 kinght주소를 가리키게 된다. 또한 clone 함수를 이용해 생성된 경우도 함수 안에서 new 키워드를 사용한 순간에 heap 영역에 별도의 kight를 가리키는 형태가 된다.

    - knight k = kight; 처럼 변수에 이미 생성된 변수를 할당한 경우 레퍼런스(참조타입)이기 때문에 같은 본체를 가리키는 형태로 저장이 된다.

    - 스택 영역의 경우 함수가 실행되고 종료되는 구간까지 현재 자신이 어느 정도의 메모리를 사용하고 있는지를 계속해서 추적한다. 이때 알아서 유동적으로 늘어나고 줄어들기 때문에 해당 영역을 따로 신경 쓸 필요가 없다. 하지만 힙 영역의 경우 메모리를 할당하고 어떤 행동도 하지 않으면 메모리가 계속 남아 유지가 된다. cpp의 경우 반드시 프로그래머가 메모리를 해제해주어야만 하는데, c#의 경우는 언어 차원에서 이를 해결해주기 때문에 더 이상 본체를 가리키는 메모리가 없을 때는 알아서 할당을 해제하여 메모리를 관리해준다.

    - 참조타입이라고 무조건 힙을 가리키는 것은 아니다. 어떤 함수를 호출하다가 mage를 그냥 호출하는 것이 아니라 ref타입으로 사용하면 본체가 아니라 주소를 사용하게 된다. 이런 본체는 stack에도 존재할 수 있다.

  • 생성자
    - 앞서 살펴본 코드에서, knight를 new 로 생성한 뒤 hp와 attack에 각각 변수값을 할당해주는 형식으로 작성했다. 하지만 이렇게 작업할 경우 자칫 변수 선언을 생략하는 등의 실수가 벌어지면 원래 의도했던 대로 객체를 생성할 수 없게 된다. 이러한 실수를 방지하기 위해, 객체 생성과 동시에 변수값을 할당해주는 작업을 생성자를 이용해 할 수 있다.

    - 생성자는 클래스와 동일한 이름을 가지지만 반환 형식은 없는 형태를 가지고 있다.
    class Knight
    {
        public int hp;
        public int attack;

        public Knight() // 생성자를 선언할 때 반환 타입을 넣으면 안된다(void 타입 포함).
        {
            hp = 100;
            attack = 10;
            Console.WriteLine("생성자 호출!");
        }

        public Knight Clone() // 클론함수를 호출하면 새로운 knight를 만들어 준다음, 자신이 가진 hp값을  자신이 가진 hp값을 넣어주고 리턴함.
                              // 진퉁도, 짝퉁도 아닌 별개의 새로운 객체를 생성하는 것.
        {
            Knight knight = new Knight(); // new 키워드로 새롭게 생성했으므로
                                           // 기존에 생성된 knight와는 별개의 객체가 된다.
            knight.hp = hp;
            knight.attack = attack;
            return knight;
        }
        public void Move()
        {
            Console.WriteLine("Knight Move");
        }
        public void Attack()
        {
            Console.WriteLine("Knight Attack");
        }

    }

    class Program
    {
        static void Main(string[] args)
        {
            Knight knight = new Knight(); // 실행하면 "생성자 호출!"문자열이 출력된다.
        }
    }

 

    - 동일한 이름의 생성자라도 매개변수의 타입이 다르면 선언과 사용이 가능하다.

    class Knight
    {
        public int hp;
        public int attack;

        public Knight() // 생성자를 선언할 때 반환 타입을 넣으면 안된다(void 타입 포함).
        {
            hp = 100;
            attack = 10;
            Console.WriteLine("생성자 호출!");
        }

        // 동일한 이름의 생성자라도 매개변수의 타입에 따라 다양하게 선언해줄 수 있다.
        public Knight(int hp) : this() // 나 자신의 빈 생성자를 호출시킨다.
                                       // 위에 작성한 생성자가 실행되기 때문에 변수 할당을 자동으로 할 수 있다.
                                       // 따라서 아래 스코프에서 사용하지 않은 attack도 10으로 할당된다.
        {
            this.hp = hp; // 인자를 할당할 변수가 Kinght 클래스에서 public으로 선언된 hp가 아니라
                          // 나 자신의 hp임을 명시적으로 표현하기 위해 this 키워드를 사용했다.
            Console.WriteLine($"hp는 {hp}, attack은 {attack}으로 할당되었습니다.");
            Console.WriteLine("int 매개변수를 가진 Knight 생성자가 사용되었다.");
        }
     }
     
    class Program
    {
        static void Main(string[] args)
        {
            Knight knight = new Knight(50); // 인자로 int 값 50을 넣어주었기 때문에 int를 매개변수로 가진
							// 2번째 Knight 생성자가 실행된다.
        }
    }

위 예제의 출력 결과는 다음과 같다.

생성자 public Knight(int hp)에 : this() 를 사용하였기 때문에 위와 같이 attack 변수가 할당되었으며, Knight() 생성자에 선언된 wirteLine함수도 함께 실행되었다.

만약 this()가 없다면 attack은 할당되지 않았기 때문에 0으로 출력되고,

"생성자 호출!" 문자열을 출력하는 wirteLine 함수도 실행되지 않는다.

 

  • Static
    - 정적 필드이기 때문에 static으로 선언된 변수는 다른 여러 개의 변수에서 호출하더라도 하나의 변수로 공용화되어 사용된다. 즉, 오로지 1개만 존재한다는 유일성을 가지게 되며, 선언된 클래스 또는 함수에 종속된다. 아래의 예제는 게임에서 유일성을 가져야하는 대표적인 변수중 하나인 id를 static을 이용해 처리하는 과정을 보여준다.
   class Knight
    {

        static public int counter; // ID를 증가시키기 위한 변수

        public int id; // MMORPG에서 각 플레이어가 하나의 고유한 ID를 가진다고 할 때
        public int hp;
        public int attack;

        public Knight()
        {
            id = counter; // ID에 현재까지 카운트된 수를 할당하고
            counter++; // 새로운 유저가 생성되었으므로 Count를 1 증가시켜준다.

            hp = 100;
            attack = 10;
            Console.WriteLine("생성자 호출!");
        }
    }

    - 함수에 Static을 사용하는 경우 일종의 공용 함수(붕어빵x, 클래스에 종속적임)가 된다. 이 경우 모두가 사용할 수 있기 때문에 static 변수가 아닌 id, hp, attack 변수를 내부에서 할당 할 수 없다. 즉 Static 함수에서는 static 변수만을 연산할 수 있음을 유의하자.

 
  - 그러나 static 함수라고 해서 무조건 일반 인스턴스에 접근할 수 없는 것은 아니다. 아래 코드처럼 생성자를 통해 접근할 클래스를 생성하면, 이렇게 생성된 클래스 내부에 있는 변수에 접근할 수 있다. 변수를 자유롭게 할당해주고 마지막으로 클래스 변수를 리턴해주기만 하면 된다.

        static public Knight CreateKnight()
        {
            Knight knight = new Knight();
            knight.hp = 100;
            knight.attack = 1;

            return knight;
        }

    - 또한 static으로 설정된 함수의 경우 해당 클래스에 종속적이기 때문에 사용을 위해 호출할 때에도 Console.WirteLine 함수처럼 종속 클래스를 통해야한다. 구체적인 예시 코드는 다음과 같다.

        public void Move()
        {
            Console.WriteLine("Knight Move");
        }
        public void Attack()
        {
            Console.WriteLine("Knight Attack");
        }

    }

    class Program
    {
        static void Main(string[] args)
        {
            Knight knight = Knight.CreateKnight();

            // static함수를 호출하고자 할 때, 이미 종속 클래스 객체가 생성되어 있는 경우 
            // 그냥 Knight.CreateKnight(); 로 호출 가능
            Knight.CreateKnight();


            // 종속적이지 않은 일반 함수들의 경우에는 
            // 샘플 객체 없이 클래스 자체만을 통해 호출 할 순 없다.
            Knight.Move(); // 불가능
            knight.Move(); // 가능
        }
    }

 

  • 상속성(Inheritance)
    - OOP의 대표적인 특징은 은닉성, 상속성, 다형성으로 총 3가지가 있다.

    - 상속을 받으면 부모 객체의 필드 뿐만 아니라 함수와 같은 기능을 사용할 수 있다.

    - 이러한 특징을 이용하여 공통적인 부분에 대해서는 부모 클래스에서 작성하고 단독으로 사용되는 기능만 자식 클래
    스에 구현하여 프로그래밍의 편의성을 높일 수 있다.

    - 상속 받은 객체를 생성하면 부모의 속성이 먼저 실행된 후 자식의 속성이 실행된다. (물이 위에서 아래로 흐르듯)

    - 생성자에서 상속 받을 객체를 명시할 때는 base 키워드를 사용한다. 아래는 각각 잘못된 예시와 옳은 예시이다.
    class Knight : Player 
    {
        public Knight() : Player(100) // 이렇게 하지 않고 base 키워드를 사용해야함
        {
            Console.WriteLine("나이트 생성자 호출");
        }

        static public Knight CreateKniht()
        {
            Knight knight = new Knight();
            knight.hp = 100;
            knight.attack = 1;
            return knight;
        }
    }
    class Knight : Player 
    {
        int c;

        public Knight() : base(100) // base : 부모의 생성자
        {
            this.c = 10; // this : 자신의 것
            base.hp = 100; // base : 부모의 것
            Console.WriteLine("나이트 생성자 호출");
        }

        static public Knight CreateKniht()
        {
            Knight knight = new Knight();
            knight.hp = 100;
            knight.attack = 1;
            return knight;
        }
    }

    - 이러한 기능을 활용해 GameObject라는 최상위 객체를 선언하고 그것을 상속받아 플레이어, 크리처(npc, 몬스터)를 나타내는 클래스로 파생해서 사용하는 방법이 있다.

 

  • 은닉성 (hiding)
    - 접근 제한자를 사용하여 프로그램의 기능들에 대한 접근을 제어할 수 있다.

    - c#의 접근 제한자에는 public, internal(동일한 Assembly 내에 있는 다른 타입들만 액세스 및 접근 가능), protected, protected internal(동일 Assembly나 포함 클래스에서 파생된 형식에서만 액세스 및 접근 가능-https://docs.microsoft.com/ko-kr/dotnet/csharp/language-reference/keywords/protected-internal), private가 있다.

    - hp, mp, attack(공격력) 처럼 자식 클래스에서 빈번하게 사용되고 접근이 필요한 변수는 protected로 선언해준다.
    class Program
    {

        // 은닉성 예제
        class Knight
        {
            private int hp; // 클래스 내부 선언 함수가 아닌 곳에서 호출할 수 없음
            // 원본 변수에 대한 고유성을 지킴 -> 무분별한 호출을 방지하여 예상치 못한 에러를 방지함
            public void SetHp(int hp) // 클래스 외부에서도 함수 호출 가능
            {
                this.hp = hp;
            }
        }
        static void Main(string[] args)
        {
            Knight knight = new Knight();
            knight.SetHp(100);
        }
    }

 

  • 클래스 형식 변환
    - 클래스의 상속성을 활용하여 불필요한 함수 중복을 방지할 수 있다.
    class Player
    {

    }

    class Knight : Player
    {

    }

    class Mage : Player
    {

    }

    class Program
    {

        // 클래스 형식 변환 예제

        static void EnterGame(Player player)
        { 
        
        }

        static void Main(string[] args)
        {
            Knight knight = new Knight();
            // 스택 영역      // 힙 영역
            // = 기호를 기준으로 스택 영역과 힙 영역이 나눠짐
            // knight -> 스택 영역에서 힙 메모리 주소를 참조함

            Mage mage = new Mage();

            // 형식 변환으로 하나의 함수에서 여러 클래스를 매개변수로 받을 수 있음
            EnterGame(knight);
            EnterGame(mage);
        }
    }

 

    - is와 as를 이용해 쉽게 클래스 형식을 변환할 수 있다.

    - is가 가능한 상황에서는 as도 사용가능하며 두 코드 모두 실행결과가 동일하게 도출된다.


    - 다만 코드상으로는 as를 활용하는 쪽이 더 깔끔하므로 이쪽이 더 추천된다.

 

   class Player
    {

    }

    class Knight : Player
    {

    }

    class Mage : Player
    {
        public int mp;
    }

    class Program
    {

        // 클래스 형식 변환 예제

        static void EnterGame(Player player)
        {
            bool isMage = (player is Mage); 
            if(isMage) // 1번째 방법 : 리턴 값이 true일 때만 캐스팅을 실행하도록
                       // 체크해주는 함수를 구현함
            {
                Mage mage = (Mage)player;
                mage.mp = 10;
            }

            // 2번째 방법 : as를 이용
            Mage mage = (player as Mage); // 반환값이 bool이 아님.
                                          // as를 사용하면 캐스팅까지 포함해서 실행됨
            if (mage != null) // 캐스팅에 성공했는지 체크한 뒤 변수를 할당해줌
            {
                mage.mp = 10;
            }


        }

        static void Main(string[] args)
        {
            Knight knight = new Knight();
            // 스택 영역      // 힙 영역
            // = 기호를 기준으로 스택 영역과 힙 영역이 나눠짐
            // knight -> 스택 영역에서 힙 메모리 주소를 참조함

            Mage mage = new Mage();

            // 클래스 강제 캐스팅의 문제점
            // Mage 타입 -> Player 타입 캐스팅 가능(Mage가 Plyer의 자식이므로)
            // Player 타입 -> Mage 타입 캐스팅 가능?
            // (꼭 그렇지는 않음->player 타입이 mage가 아니라 knight 타입이 변환된 것일 수 있기 때문에) 
            Player magePlayer = mage;
            Mage mage2 = (Mage)magePlayer; // 위의 문제를 해결하기 위해 강제 캐스팅을 사용해봄
                                           // 그러나 강제 캐스팅 했을 때 캐스팅이 잘못 걸릴 수도 있음
                                           // 문법상으로는 문제가 없기 때문에 캐스팅이 잘못되었음을
                                           // 실제로 실행을 해봐야만 알 수 있기 때문에 위험한 코드임

            // ** 결론 **
            // 자식 클래스에서 부모 클래스로 변환하는 것은 문제 없이 가능하지만
            Player p1 = knight;

            // 반대로 부모 타입에서 자식 타입으로 넘어가는 것은 케바케(될 수도 있고 안될 수도 있다)
            Mage mage = (Mage)Player; // 강제 캐스팅할 클래스에 대해서 확신이 있으면 변환해도 되지만
                                      // 상술한 문제 때문에 프로그램이 뻗을 수 있으므로 매우 유의해야한다.

            // 이러한 문제를 최대한 방지하기 위해
            // 사전에 체크하는 방법이 있다. (EnterGame함수의 추가 부분 참고)
            

            // 형식 변환으로 하나의 함수에서 여러 클래스를 매개변수로 받을 수 있음
            EnterGame(knight);
            EnterGame(mage);
        }
    }

 

 +) null 응용

        // +) Null의 응용
        // null : 아무것도 없다. 즉, 참조하는 타입이 아무것도 가리키고 있지 않은 상태
        static Player FindPlayerById(int id)
        {
            // id에 해당하는 플레이어를 탐색

            // 못찾았으면
            return null;
        }

 

  • 다형성(polymorphism)
    - 다형성이 필요한 이유?
    : 똑같은 이동 기능을 가진 경우에도 직업에 따라 Knight는 일반적인 걷기 기능을 가지고, Mage는 mp를 소모해서 텔레포트를 할 수도 있다. 이 경우 공통된 move 함수를 두고 용도에 따라 내용을 수정해주면 구현이 간단해지며 유지보수에 용이하다.

    - 가상(virtual)함수와 오버라이드(override)를 사용해 다형성을 가진 클래스를 구현할 수 있다.

    - 오버라이드를 사용하기 위해서는 반드시 상위 객체에서 virtual 키워드를 사용해 가상 함수 선언을 해주어야만 한다. 이때의 상위 객체는 반드시 직전 객체일 필요는 없다. ex) player를 상속받은 knight가 있고, 이 knight를 상속받은 superKnight라는 것이 있다고 가정할 때, knight에 virual로 선언된 move 함수가 없어도 player에 virual 함수 move가 존재한다면 superKnight에서도 똑같이 이 move 함수를 사용할 수 있게 된다.

    - 오버라이딩은 하나의 함수가 실제 타입에 따라 다양하게 동작하게 되는 것을 의미한다.

    - ** 오버라이드(= 오버라이딩, 다형성을 만드는 것)와 오버로딩(함수 이름의 재사용, 이름은 같지만 인자가 다른 경우를 오버로딩이라 한다.)은 서로 다른 키워드이므로 헷갈리지 않도록 주의해야 한다.

    - virtual을 사용하는 함수는 일반 함수보다 성능에 부하를 주기 때문에 모든 함수를 virtual로 사용해선 안된다. 꼭 다형성이 필요한 경우에만 virtual과 오버라이드를 활용하도록 하자.
    class Player
    {
        protected int hp;
        protected int attack;

        public virtual void Move()
        {
            Console.WriteLine("Player 이동!");
        }

    }

    class Knight : Player
    {
        public override void Move()
        {
            Console.WriteLine("Knight 이동!");
        }

    }

    class Mage : Player
    {
        public int mp;
        public override void Move()
        {
            Console.WriteLine("Mage 이동!");
        }
    }

    class Program
    {

        static void EnterGame(Player player)
        {
            Mage mage = (player as Mage);
            if (mage != null)
            {
                mage.mp = 10;
            }
        }

        static void Main(string[] args)
        {
            Knight knight = new Knight();
            Mage mage = new Mage();

            knight.Move(); // "Knight 이동!" 이 출력된다.
            mage.Move(); // "Mage 이동!" 이 출력된다.

            EnterGame(mage);
        }
    }
  class Player
    {
        protected int hp;
        protected int attack;

        public virtual void Move()
        {
            Console.WriteLine("Player 이동!");
        }

    }

    class Knight : Player
    {
        public override void Move()
        {
            Console.WriteLine("Knight 이동!");
        }

    }

    class Mage : Player
    {
        public int mp;
        public override void Move()
        {
            Console.WriteLine("Mage 이동!");
        }
    }

    class Program
    {

        static void EnterGame(Player player) // 인자로 받은 객체가 어떤 타입의 인스턴스인지에 따라서
                                             // 그 타입에 맞는 버전의 함수를 자동으로 호출해준다.
        {
            player.Move();
            Mage mage = (player as Mage);
            if (mage != null)
            {
                mage.mp = 10;
            }
        }

        static void Main(string[] args)
        {
            Knight knight = new Knight();
            Mage mage = new Mage();

            knight.Move();
            mage.Move();

            EnterGame(mage); // "Mage 이동!"이 출력됨.
        }
    }

 

    - 부모 객체가 가진 가상 함수를 한번은 호출 및 재사용하고 싶을 때는 base.함수()로 부모를 호출한 다음 자식 객체에서 이어서 실행하고 싶은 내용을 호출할 수 있다.

    class Player
    {
        protected int hp;
        protected int attack;

        public virtual void Move()
        {
            Console.WriteLine("Player 이동!");
        }

    }

    class Knight : Player
    {
        public override void Move()
        {
            base.Move(); // 부모인 player의 move를 한번 호출 함. "Player 이동!"이 출력된 다음
            Console.WriteLine("Knight 이동!"); // 자신의 내용이 호출됨
        }
    }

 

+) sealed 키워드의 활용 (봉인)

    class Knight : Player
    {
        public sealed override void Move() // Knight(본인)까지는 오버라이드로 함수를 재정의 할 수 있지만,
                                           // 이 knight를 상속받는 자식들은 이 함수를 재정의할 수 없도록 봉인함.  
        {
            base.Move(); // 부모인 player의 move를 한번 호출 함
            Console.WriteLine("Knight 이동!");
        }

    }

 

  • 문자열 둘러보기
static void Main(string[] args)
        {
            string name = "Harry Potter";

            // 1. 찾기
            bool found = name.Contains("Harry"); // 특정 문자열을 포함하고 있는지 확인하여 true/false 리턴
            int index = name.IndexOf('P'); // 특정 문자가 있는 인덱스 값(int)을 리턴


            // 2. 변형
            name = name + "Junior";
            
            string lowerCaseName = name.ToLower(); 
            // 해당 문자열을 모두 소문자로 바꾸어 반환

            string UpperCaseName = name.ToUpper(); 
            // 해당 문자열을 모두 대문자로 바꾸어 반환

            string newName = name.Replace('r', 'l'); 
            // 특정 문자열을 , 뒤에 오는 문자열로 변환

            // 3. 분할
            string[] names = name.Split(new char[] { ' ' });
            // 입력한 문자열을 기준으로 문자열을 잘라서 배열에 저장

            string subStringName = name.Substring(5);
            // 입력한 인덱스부터 문자열을 잘라 새롭게 string 변수에 저장함 
            
        }

    - 아래는 디버깅 모드로 확인한 출력 결과이다. 

 

카테고리
작성일
2022. 8. 2. 12:58
작성자
risehyun

C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 VS에서의 디버깅에 대해 정리해본다.

 


{
    // 메소드 호출 -> inception
    // 현실 -> 1차 꿈 -> 2차꿈

    class Program
    {
        static void Print(int value)
        {
            Console.WriteLine(value);
        }

        static int AddAndPrint(int a, int b)
        {
            int ret = a + b;
            Print(ret);
            return ret;
        }

        static void Main(string[] args) // 메인을 현실이라고 가정했을 때,
                                        // F10을 누르면 현실에서 움직임
                                        // F11을 누르면 꿈(함수) 안으로 들어감
        {
            Program.AddAndPrint(5, 15);
            Program.AddAndPrint(6, 17);
            Program.AddAndPrint(3, 11);
            Program.AddAndPrint(12, 31);
            Program.AddAndPrint(10, 20);
        }
    }

 

  • 디버깅 단축키 : F5

  • 프로시저 단위 실행(F10) : 실행단위가 프로시저(=함수, 메소드)이다. 함수를 실행하고 다음 줄로 넘어간다. 어떤 함수를 만나더라도 안으로 들어가지 않고 큰 블록 단위로 실행하는 옵션이다.
  • 한 단계씩 실행(F11) : 진행 중에 함수를 만나면 해당 함수의 스코프 안으로 들어가 한 단계씩 세부적으로 실행한다.

  • 조사식의 이름 항목 안에 현재 할당된 변수를 넣으면 변수의 값을 알 수 있다. 또한 값 부분에 임의의 값을 넣어 줄 수도 있다.

  • 중단점을 설정한 라인에서 오른쪽 마우스를 누르고 조건을 클릭하면 조건식 안에 값을 넣어 특정 조건을 충족할 때만 브레이크 포인트가 걸리도록 설정할 수 있다. 이 기능을 여러 방법으로 활용할 수 있다.

    EX) RPG 게임 개발 시, 수많은 몬스터 중에서 특정 아이디의 몬스터를 관찰하고 싶을 때 조건으로 아이디를 넣어주면 해당 몬스터의 정보만 골라서 볼 수 있다.

 

  • 브레이크 포인트를 사용하지 않고 로그를 남겨서 디버깅하는 방법도 있지만(ex. 유니티 Debug.Log) 실제 내부의 작동 구조를 알 수 없으며 계속해서 코드를 추가해야 한다는 부담이 있다. 그러나 브레이크 포인트는 개발 단계에서만 디버깅이 가능하고, 실제 서비스 중인 프로그램의 경우에는 브레이크 포인트를 이용한 디버그를 할 수 없다.

  • 디버깅 중에 브레이크 포인트를 드래그하여 원하는 위치로 노란 화살표를 이동시키면 과거로 돌아가 원하는 조건을 스킵하거나 특정 함수 안으로 들어가거나, 강제로 명령어를 실행하는 등 실행순서를 임의로 설정할 수 있다.
  • 알 수 없는 이유로 프로그램이 뻗어버렸을 때, 중단점을 맨 끝에 둔 상태로 디버그 모드 -> 상단의 모두중단을 클릭하면 가장 마지막으로 실행한 줄에서 멈추게 된다. 해당 부분에서 무언가 문제가 생겼을 가능성이 높으므로 그 부분을 집중적으로 확인하면 편하게 디버깅할 수 있다.

 

 

 

 

 

'TIL' 카테고리의 다른 글

23-05-11  (0) 2023.05.11
23-05-10  (0) 2023.05.10
23-04-05 TIL  (0) 2023.04.05
220715 비헤이비어 트리 설계 변경  (0) 2022.07.15
220714 비헤이비어 트리 (Behavior Tree, BT) 정리  (0) 2022.07.14
카테고리
작성일
2022. 7. 18. 23:58
작성자
risehyun

C#과 유니티로 만드는 MMORPG 게임 개발 시리즈를 수강하며 연습문제를 풀어보았다.

 


    class Program
    {
        // <연습 문제 : 구구단>
        static void Main(string[] args)
        {
            for (int i = 2; i <= 9; i++)
            {
                for (int j = 1; j <= 9; j++)
                {
                    Console.WriteLine($"{i} * {j} = {i*j}");
                }
            }
        }
    }
        // <연습 문제 : 간단한 별 피라미드>
        static void Main(string[] args)
        {
            for (int i = 0; i < 5; i++)
            {
                for (int j = 0; j <= i; j++)
                {
                    Console.Write("*");
                }
                Console.WriteLine();
            }
        }
    class Program
    {
        static int Factorial(int n)
        {
            int ret = 1;
            for (int num = 1; num <= n; num++)
            {
                ret *= num;
            }
            return ret;
        }

        // <연습 문제 : 팩토리얼>
        static void Main(string[] args)
        {
                // 5! = 5 * 4 * 3 * 2 * 1
                // n! = n * (n-1) ... * 1 (n >= 1)
                int ret = Factorial(5);
                Console.WriteLine(ret);
        }
    }
        // <연습 문제 : 재귀함수로 팩토리얼 구현 - 오류>
        // 재귀 함수 : 함수 안에서 다시 자신을 호출하는 것
class Program
    {
        static int Factorial(int n)
        {
            return n * Factorial(n - 1);
        }


        static void Main(string[] args)
        {
                // 5! = 5 * (4!)
                // 5! = 5 * 4 * 3 * 2 * 1
                // n! = n * (n-1) ... * 1 (n >= 1)
                int ret = Factorial(5);
                Console.WriteLine(ret); // 종료 조건을 지정하지 않았기 때문에 스택 오버플로우가 발생함
        }
    }
    
            // <연습 문제 : 재귀함수로 팩토리얼 구현 - 정상작동>
        class Program
    {
        static int Factorial(int n)
        {
            if (n <= 1) // 종료 조건 추가
                return 1;
            return n * Factorial(n - 1);
        }


        static void Main(string[] args)
        {
                // 5! = 5 * (4!)
                // 5! = 5 * 4 * 3 * 2 * 1
                // n! = n * (n-1) ... * 1 (n >= 1)
                int ret = Factorial(5);
                Console.WriteLine(ret); // 종료 조건을 지정했기 때문에 정상 실행됨
        }
    }

 

'C#' 카테고리의 다른 글

220817 Interface (인터페이스)  (0) 2022.08.17
[C#] 일반화(Generic)  (0) 2022.08.16
220816 배열과 컬렉션  (0) 2022.08.16
[C#] 문법 정리  (0) 2022.07.17
220716 C# 람다식  (0) 2022.07.16
카테고리
작성일
2022. 7. 17. 23:54
작성자
risehyun

C#과 유니티로 만드는 MMORPG 게임 개발 시리즈를 수강하며 개인적으로 정리가 필요하다 생각한 기초 문법을 다시 살펴보았다.

 


 

1. 데이터 연산 - 전위/후위 증감 연산자

전위/후위 연산자 중 어느 쪽을 사용해도 연산에 의한 최종 변수 값은 같게 나온다. 다만 둘은 작동 순서에 차이가 있다.

 

  • 전위 증가 연산자
    - 함수(WriteLine)이 실행되기 전에 증가 연산자가 먼저 작동한다. 따라서 함수에 의한 출력 값과 변수 값이 모두 같다.
    class Program
    {
        static void Main(string[] args)
        {
            // <전위 증가 연산자>
            int hp = 100;

            Console.WriteLine(++hp);

            // hp의 값은 101, 출력 값도 101이 됨
        }
    }

 

  • 후위 증가 연산자
    - 함수(WriteLine)이 실행된 후에 증가 연산자가 다음으로 작동한다. 따라서 함수에 의한 출력 값은 증가 연산 수행 이전의 값인 100이 나오는 반면 변수 값은 함수 실행 이후 증가하였으므로 101이 된다.
    class Program
    {
        static void Main(string[] args)
        {
            // <후위 증가 연산자>
            int hp = 100;

            Console.WriteLine(hp++);

            // hp의 값은 101, 출력 값은 100이 됨
        }
    }

 

2. 데이터 연산 - 비트 연산자

 - 2진수 표현에 대해 알고 있으면 금방 넘어갈 수 있는 내용들이다.

혹시 이 부분이 이해가 되지 않는다면 진법 변환과 논리 연산에 대해 공부해야 한다.

나의 경우엔 논리 회로, 컴퓨터 구조 강의에서도 배웠던 내용이므로 간단하게 다시 정리해보려 한다.

  • 시프트 연산자
    class Program
    {
        static void Main(string[] args)
        {
            // <시프트 연산자>
            // 변수 << 밀어낼 자릿 수

            int num = 1;

            num = num << 1;
            // num 변수의 값 1에서 1만큼 왼쪽 뱡항으로 시프트(<<) 해줌.

            Console.WriteLine(num);
            // 시프트 연산 영향으로 0001 에서 0010이 되었으므로 2가 출력됨.

        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            // <시프트 연산자>
            // 변수 >> 밀어낼 자릿 수

            int num = 8;

            num = num >> 1;
            // num 변수의 값 8(1000)에서 1만큼 시프트(>>) 해줌.

            Console.WriteLine(num);
            // 시프트 연산 영향으로 1000 에서 0100이 되었으므로 4가 출력됨.
            
            /*
            * <주의 할 점>
            * (>>) 연산을 수행할 때 변수의 값이 음수라면, 음/양수를 나타내는 첫 번째 비트 자릿수가 1이 된다.
            * 이 상태에서 시프트 연산으로 비트가 자리 이동을 하면 
            * 자릿수는 고정된 채로 같은 값을 연산 방향에 따라 시프트하게 된다.
            */
        }
    }

 

  • &(AND), |(OR), ^(XOR), ~(NOT) 연산자
    - AND 연산 : 각 비트끼리 값을 비교하여 서로 같은 자리에 1이 있을 경우에만 최종 연산 값의 해당 자리가 1이 된다.
    - OR 연산 : 각 비트끼리 값을 비교하여 둘 중 1을 가진 자리가 1개 이상인 경우 최종 연산 값의 해당 자리가 1이 된다.
    - XOR 연산 : 각 비트끼리 값을 비교하여 홀수 개의 1이 있는 경우(EX. 1이 1개, 1이 3개)  해당 자리가 1이 된다.
    - NOT 연산 : 각 비트의 값을 반대로 뒤집어준다. (EX. 1이라면 0, 0이라면 1)

  • 비트 연산자를 게임에서 활용하는 예시
    - ID를 만들 때 : 32비트를 4비트씩 쪼개어 처음 4비트에는 NPC인지 플레이어인지, 그다음 4비트에는 지역이 어디인지 등.. 각각에 정보를 저장하는데, 이때 시프트 연산자를 사용해 원하는 위치로 값을 이동시키고 OR 연산을 활용한 삽입 연산으로 비트를 서로 연결시켜 최종적으로 원하는 정보가 모두 담긴 ID를 완성시킬 수 있다.

    - 유니티 엔진에서 : 레이아웃 값을 코드에서 불러와 활용하고 싶을 때 부여된 숫자 값을 시프트 연산으로 선택하는 방법도 있다.

    - XOR의 경우 암호화에 사용할 수 있다. 똑같은 값에 똑같은 숫자를 XOR을 2번 해주면 원래 값이 다시 나온다는 독특한 특징이 있기 때문이다. 강의에서 제시한 예시 코드는 다음과 같다.
    class Program
    {
        static void Main(string[] args)
        {
            // <XOR 연산>

            int id = 123;
            int key = 401;

            int a = id ^ key; // XOR 연산 1회 실행
            int b = a ^ key; // 앞의 결과에 다시 한번 key 가지고 2번째 XOR 실행

            Console.WriteLine(a); // 490이 출력됨 (XOR 1회 실행 결과)
            Console.WriteLine(b); // 123이 출력됨 (XOR 2회 실행 결과)

        }
    }

 

3. 데이터 연산 - 연산자 우선순위

동일 식에서 여러 개의 연산자가 사용될 때 각각의 우선순위에 따라 먼저 실행되는 연산이 정해진다.

 

1. ++ --

2. * / %

3. + -

4. << >>

5. < >

6. == !=

7. &

8. ^

9. |

 

등.. 정해진 우선순위가 있지만 이걸 모두 외우는 것은 비효율적이고,

우선순위를 알고 있다고 해도 괄호 없이 식을 쓰면 코드 가독성에 좋지 않다.

식 안에서 여러 연산자가 필요할 때는 우선 괄호를 적극적으로 활용해 우선순위를 강제로 지정해주자.

 

4. C#에서의 var 타입

프로그램을 작성할 때 꼭 필요한 경우를 제외하면 var 타입을 남용하지 않는 것이 좋다.

코드를 읽는 사람이 명확하게 이해할 수 없다는 문제가 있기 때문이다.

따라서 일반 변수 등을 선언할 때는 기존 타입을 사용해 명시적으로 나타내야 한다.

 

5. 삼항연산자

        static void Main(string[] args)
        {

            // <삼항 연산자>

            int num = 25;
            bool isPair = ((num % 2) == 0 ? true : false);
            //              ( 조건식      ? 맞을 때 : 틀릴 때)

            //삼항 연산자를 사용하지 않으면 다음과 같이 길게 입력해야 하는 불편함이 있음
            if (num % 2 == 0)
                isPair = true;
            else
                isPair = false;
        }

 

6. Switch문에서의 조건 처리

  • 정수 자료형(int, 문자 자료형 char 포함)만 사용 가능하다. 
  • 따라서 Enum 타입을 사용하려 할때는 (int) 캐스팅이 필요하다.
  • int 자료형을 조건변수로 둘 때 변수명 그대로 사용하려면 const 키워드를 사용하여 상수로 변경 해준다.
  • 처리시 조건을 0, 1, 2과 같이 두는 등, 의미를 명확히 알 수 없는 단순 하드코딩을 지양해야 한다.
    class Program
    {
        static void Main(string[] args)
        {

            // <하드코딩된 가위바위보게임 개선 - switch의 자료형>

            const int ROCK = 1; // 변하지 않는 / 변해서는 안되는 상수 변수이므로 대문자로 처리해주었음
            const int PAPER = 2;
            const int SCISSORS = 0;

            int choice = Convert.ToInt32(Console.ReadLine());

            switch (choice)
            {
                case SCISSORS:
                    Console.WriteLine("당신의 선택은 가위입니다.");
                    break;

                case ROCK:
                    Console.WriteLine("당신의 선택은 바위입니다.");
                    break;

                case PAPER:
                    Console.WriteLine("당신의 선택은 보입니다.");
                    break;
            }
        }
    }

 

7. 열거형 타입 Enum

    class Program
    {
        enum Choice
        {
            Rock = 1, // 디폴트로는 맨 위에서 부터 0, 1, 2 ... 이런식으로 숫자가 매겨지지만 강제로 지정해줄 수도 있다.
            Paper = 2,
            Scissor = 0
        }

        static void Main(string[] args)
        {

            // <하드코딩된 가위바위보게임 개선 - 열거형 enum 활용>

            int choice = Convert.ToInt32(Console.ReadLine());

            switch (choice)
            {
                case (int)Choice.Scissor: // enum값은 정수가 아니기 때문에 사용시 int 타입 캐스팅이 필요하다.
                    Console.WriteLine("당신의 선택은 가위입니다.");
                    break;

                case (int)Choice.Rock:
                    Console.WriteLine("당신의 선택은 바위입니다.");
                    break;

                case (int)Choice.Paper:
                    Console.WriteLine("당신의 선택은 보입니다.");
                    break;
            }

        }
    }

 

8. while문과 do while문의 차이

        static void Main(string[] args)
        {
            // while 반복문
            int count = 5;

            while (count > 0) // 진입시 먼저 조건을 판별한 뒤 조건이 성립하면 스코프 안의 코드를 실행시킴
            {
                Console.WriteLine("Hello World!");
                count--;
            }

            // do while문
            do // 진입시 스코프 안의 코드를 먼저 1회 실행시키고 그 다음에 while문에 대한 
               // 조건식을 판별하여 성립시 다시 do 스코프 안의 코드를 실행시킨다.
               // 반드시 최초 1회를 실행시켜야만 하는 코드를 작성할 때 사용.
            {

            } while (조건식);


        }
  • do while문 예제
        static void Main(string[] args)
        {
            // <do while문 예제>

            string answer = "";

            do
            {
                Console.WriteLine("강사님은 잘생기셨나요?"); // 일단 질문을 던진 다음
                // string answer = Console.ReadLine(); // do 스코프 안에서만 유효한 변수이므로 위에서 선언하는 것으로 변경함
                answer = Console.ReadLine();
            } while (answer != "y"); // 답변이 y가 아니면 계속해서 질문을 한다.

            Console.WriteLine("정답입니다!");
        }
    }

 

9. break문

  • while, for, switch문과 같은 반복문에서 사용시 해당 반복문의 범위 스코프 밖으로 빠져나가는 키워드 
    class Program
    {
        static void Main(string[] args)
        {
            // <break문 예제>

            int num = 97; // 1, 97로만 나뉘는 숫자

            bool isPrime = true;

            for (int i = 2; i < num; i++)
            {
                if ((num % i) == 0)
                {
                    isPrime = false;
                    break; // if문이 아닌 for문 자체를 벗어남.
                }
            }

            if (isPrime)
                Console.WriteLine("소수입니다!");
            else
                Console.WriteLine("소수가 아닙니다!");

        }
    }

 

+) 다음과 같은 상황에서는 break가 아닌 return을 이용해 함수 밖으로 빠져나갈 수 있다.

결과는 유사해 보이지만 서로 다른 작동이므로 유의해야 한다.

        static void EnterGame()
        {
            while (true)
            {
                Console.WriteLine("게임에 접속했습니다!");
                Console.WriteLine("[1] 필드로 간다");
                Console.WriteLine("[2] 로비로 돌아간다");

                string input = Console.ReadLine();
                switch (input)
                {
                    case "1":
                        // EnterField();
                        break; // switch문을 벗어남.

                    case "2":
                        return; // EnterGame() 함수 자체에 대한 return 이므로
                                // 함수가 실행 종료되며, 결론적으로 로비(캐릭터선택)로 돌아가게 된다.
                }
            }
        }

 

또한 후술할 코드는 상술한 코드와 동일한 결과가 나오지만 break가 작동되는 범위가 if문이 아니라 while문 자체라는 차이가 있다. break 항목의 1번째 코드와 같은 양상이다.

        static void EnterGame()
        {
            while (true)
            {
                Console.WriteLine("게임에 접속했습니다!");
                Console.WriteLine("[1] 필드로 간다");
                Console.WriteLine("[2] 로비로 돌아간다");

                string input = Console.ReadLine();
                if (input == "1")
                {
                    // EnterField()
                }

                else if (input == "2")
                {
                    break; // if문이 아닌 while문 자체를 빠져나가기 때문에,
                           // 결론적으로 함수가 종료되며 로비(캐릭터 선택)으로 돌아간다.
                }
            }
        }

 

10. continue문

    class Program
    {
        static void Main(string[] args)
        {
            // <Continue문 예제>
            // 1에서 100까지의 숫자 중에서 3으로 나뉘는 숫자를 출력하는 프로그램

            for (int i = 1; i < 100; i++)
            {
                if ((i % 3) != 0)
                    continue; // 코드가 조건을 체크한다음 3으로 나뉘는 숫자가 아니라면
                              // continue 키워드 아래에 적한 코드들을 실행하지 않고 바로 다음 루프로 넘어감.

                Console.WriteLine($"3으로 나뉘는 숫자 발견 : {i}");
            }
        }
    }
  • 이 예제에서 continue문을 사용하는 쪽이 if문으로 조건 검사 후 바로 WriteLine을 실행하는 것보다 효율적인 이유는 조건에 만족하지 않을 때 바로 다음 루프로 넘어갈 수 있기 때문에 코드가 더욱 직관적이기 때문이다.
  • 또한 해당 코드에서 조건문이 점차 많아진다고 가정하면 if((i%3 != 0 && ... && ...) 이런식으로 조건 판별식이 복잡해질 수 있기 때문에 continue문을 이용하여 깔끔하게 처리하는 것이 좋다.
    - EX) MMO게임에서 힐러가 전체 버프 스킬을 사용할 때, 파티원에게만 해당 버프가 적용되도록 하려면 for문을 이용해 주변 캐릭터들을 모두 검사하다가 우리 파티원에 해당하는 캐릭터가 아니면 바로 continue를 사용하면 바로 이어서 다음 캐릭터를 검사하도록 구현할 수 있다.

11. 함수

  • 기본적인 함수 선언과 호출에 대한 예제
    class Program
    {
        // <함수(= Method, Function, Procedure 예제>
        // <기본형식>
        // 한정자 반환형식 이름(매개변수목록)
        // {
        //
        // }

        static void HelloWorld()
        {
            Console.WriteLine("Hello World!");

        }

        static void Main(string[] args)
        {
            HelloWorld(); // 함수가 실행되어 Hello World! 가 출력됨
        }
    }
    class Program
    {
        // <함수(= Method, Function, Procedure 예제>
        // <덧셈 함수>

        static int Add(int a, int b)
        {
            int result = a + b;
            return result;

//            return result = a + b; -> 위 코드와 동일함.
        }

        static void Main(string[] args)
        {
           
//            int result = Program.Add(4, 5); -> 아래 코드와 동일함.
            int result = Add(4, 5);  
            Console.WriteLine(result); // 4, 5 => 9가 출력됨.

        }
    }

 

  • 함수 사용시 매개 변수를 복사로 넘겨준 경우와 참조로 넘겨준 경우의 차이
    class Program
    {
        // <함수 예제2>
        static void AddOne(int number)
        {
            number += 1; // 0이라는 값을 증가시키기는 하지만,
                         // 이 값은 실제로 main에 선언된 a가 가지고 있는 메모리 값과는 다르다.
        }

        static void Main(string[] args)
        {
            int a = 0; // 매개변수를 복사로 넘겨준 경우 (아무것도 명시되지 않았을 때 디폴트)
            Program.AddOne(0);
            Console.WriteLine(a); // 0이 출력된다.
        }
    }
    class Program
    {
        // <함수 예제2>
        // 실제로 값을 증가시키는 함수를 구현하기 위한 ref 키워드 추가 ver.
        static void AddOne(ref int number) // ref = 레퍼런스. 참조 값으로 실제 a의 메모리 값을 바꾸어줌.
        {
            number += 1;
        }

        static void Main(string[] args)
        {
            // 복사(짝퉁) vs 참조(진퉁)
            int a = 0;
            Program.AddOne(ref a); // 참조값을 넘길 때는 호출 시에도 키워드로 명시 한다.
            Console.WriteLine(a); // 실제 a값이 변화하였기 때문에 1이 출력된다.
        }
    }

 

12. ref와 out

    class Program
    {
        // <예제>
        // Q. ref를 붙이면 진퉁으로 작업하는 것이라고 했는데,
        // 처음 함수를 만들 때 반환값에 int를 넣고 반환을 받는 것과
        // 입력받는 매개변수 int에 ref 키워드를 붙여서 진퉁으로 작업하는 것은
        // 근본적으로 무슨 차이가 있는가?

        static void AddOne(ref int number) // 참조를 활용한 함수가 필요하다면 이쪽을 사용
                                           // ex) swap 함수 만들기와 같은 경우
        {
            number = number + 1;
        }

        static int AddOne2(int number) // 범용적으로는 이런 일반 함수 형식을 사용하는 것이 깔끔
        {
            return number + 1;
        }

        static void Main(string[] args)
        {
            int a = 0;
            Program.AddOne(ref a);
            Console.WriteLine(a); // 1이 출력됨

            int b = Program.AddOne2(a);
            a = b;
            Console.WriteLine(a); // 2가 출력됨
        }
    }

 

  • ref (참조)를 사용하는 함수 구현 예제
    class Program
    {
        // <ref 예제>
        // Q. ref를 붙이면 진퉁으로 작업하는 것이라고 했는데,
        // 처음 함수를 만들 때 반환값에 int를 넣고 반환을 받는 것과
        // 입력받는 매개변수 int에 ref 키워드를 붙여서 진퉁으로 작업하는 것은 무슨 차이가 있는가?
        // => ref 사용시 근본이 바뀐다는 점이 다름!

        static void Swap(ref int a, ref int b) // a와 b의 값을 서로 바꿔주는 함수
        {
            int temp = a;
            a = b;
            b = temp;
        }

        static void Main(string[] args)
        {
            int num1 = 1;
            int num2 = 2;
            Program.Swap(ref num1, ref num2);

            Console.WriteLine(num1); // 2가 출력됨
            Console.WriteLine(num2); // 1이 출력됨
        }
    }

 

  • out 을 사용하는 함수 구현 예제
    class Program
    {
        // <out 예제>

        static void Divide(int a, int b, out int result1, out int result2) 
            // a를 b로 나눈 결과값을 result1에 넣고, 나머지 값인 result2에 넣어주는 함수
        {
            result1 = a / b; // ref와 달리 out 키워드를 사용하는 경우 반드시 값을 넣어주어야만 한다.
            result2 = a % b; // out 키워드도 ref와 마찬가지로 참조를 이용하게 된다.
        }

        static void Main(string[] args)
        {
            int num1 = 10;
            int num2 = 3;

            int result1;
            int result2;
            Divide(10, 3, out result1, out result2); // ref와 마찬가지로 사용시 out 키워드를 명시해야함

            Console.WriteLine(result1);
            Console.WriteLine(result2);
        }
    }

 

13. 오버로딩

    class Program
    {
        // <오버로딩 예제>
        // 오버로딩 : 함수 이름을 재사용 하는 것

        static int Add(int a, int b)
        {
            return a + b;
        }

        /*        static int Add(int a, int b) // 동일한 이름의 함수를 사용할 때 
                                             // 매개변수의 구성(갯수포함)이 같으면 안된다.
                {
                    return a + b;
                }*/

        /*        static void Add(int a, int b) // 리턴 타입이 다르더라도 매개변수 구성이 같으면 안된다.
                {
                    return a + b;
                }*/

        static float Add(float a, float b)
        {
            return a + b;
        }


        static void Main(string[] args)
        { 
            int ret = Program.Add(2, 3);          // int 리턴 타입의 add 함수가 호출됨
            float ret2 = Program.Add(2.0f, 3.0f); // float 리턴 타입의 add 함수가 호출됨

            Console.WriteLine(ret);
        }
    }

 

  • 선택적 매개변수에 대한 예제
    class Program
    {
        // <오버로딩 예제 - 선택적 매개변수>

        static int Add(int a, int b, int c = 0, float d = 5.0f) // 2개의 변수에 대해서는 필수로 입력을 받고,
                                                // 3번째 변수 c는 기본 값을 가지고 있는 옵션이다.
                                                // 3번째 변수 자리에 아무 것도 입력하지 않으면 기본값이 할당된다.
                                                // 여러개의 매개변수를 사용할 때는 각 변수의 자리에 유의해야한다.
        {
            return a + b + c;
        }

        static void Main(string[] args)
        {
            int ret = Program.Add(1, 2, d: 2.0f); // 변수 입력 순서를 반드시 지켜야하는 C++과 달리,
                                                  // C#에서는 함수 호출 시 필요한 변수를 지정해서 값을 넣어줄 수 있다.
                                                  // 이 경우에는 지정값 외에 다른 값은 모두 기본값이 할당되게 된다.
            Console.WriteLine(ret);
        }
    }

 

14. 직렬화

오브젝트의 인스턴스를 바이너리 혹은 텍스트 형식으로 변환하는 과정을 직렬화라고 한다.

이를 활용해 오브젝트의 상태를 파일로 저장할 수 있다.

'C#' 카테고리의 다른 글

220817 Interface (인터페이스)  (0) 2022.08.17
[C#] 일반화(Generic)  (0) 2022.08.16
220816 배열과 컬렉션  (0) 2022.08.16
220718 C# 연습문제 풀이  (0) 2022.07.18
220716 C# 람다식  (0) 2022.07.16
카테고리
작성일
2022. 7. 16. 23:55
작성자
risehyun

C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의를 수강하며 람다식에 대해 배웠다.

 

1. 람다식을 사용하지 않았을 때

    enum ItemType
    { 
        Weapon,
        Armor,
        Amulet,
        Ring
    }

    enum Rarity
    { 
        Normal,
        Uncommon,
        Rare
    }

    class Item
    {
        public ItemType ItemType;
        public Rarity Rarity;
    }

    static List<Item> _items = new List<Item>(); // 가상의 인벤토리처럼 아이템 목록 역할을 하는 리스트

    // 인벤토리에서 특정 아이템을 찾고 싶을 때 함수 구현(Find 함수를 하나씩 만든 Bad Ver.)
    static Item FindWeapon()
    {
        // 아이템을 쭉 스캔하면서 찾으려는 무기가 있는지 보고, 있으면 바로 그 아이템을 반환
        foreach (Item item in _items)
        {
            if (item.ItemType == ItemType.Weapon)
                return item;
        }
        return null; // 반환이 안되면 없다는 뜻이므로 null 리턴
    }

    // 현재 가지고 있는 희귀 등급의 아이템을 찾고 싶을 때(Bad ver.)
    static Item FindRareItem()
    {
        // 아이템을 쭉 스캔하면서 찾으려는 무기가 있는지 보고, 있으면 바로 그 아이템을 반환
        foreach (Item item in _items)
        {
            if (item.Rarity == Rarity.Rare)
                return item;
        }
        return null; // 반환이 안되면 없다는 뜻이므로 null 리턴
    }

    static void Main(string[] args)
    {
        _items.Add(new Item() { ItemType = ItemType.Weapon, Rarity = Rarity.Normal }); // 아이템을 만들면서 동시에 정보를 넣어 ADD 해주는 문법
        _items.Add(new Item() { ItemType = ItemType.Armor, Rarity = Rarity.Uncommon });
        _items.Add(new Item() { ItemType = ItemType.Ring, Rarity = Rarity.Rare });
    }

    /*
     * 기능을 몇 개 구현하지도 않았는데 너무 많은 함수들이 생김.
     * 그리고 아이템을 찾는 조합이 몇개인지를 미리 예상하기도 힘듦. 즉 무식한 방법!
     * 심지어 지금처럼 foreach로 끝나지 않고 인벤토리 내부를 서치하는 내용이 아주 길어질 수 있기 때문에
     * 그걸 일일이 복붙하는 것은 비효율적임.
     * 
     * => 이런 문제를 해결하기 위해 델리게이트를 사용함
     */

 

2. 같은 내용을 델리게이트를 사용한 버전으로 수정

    enum ItemType
    { 
        Weapon,
        Armor,
        Amulet,
        Ring
    }

    enum Rarity
    { 
        Normal,
        Uncommon,
        Rare
    }

    class Item
    {
        public ItemType ItemType;
        public Rarity Rarity;
    }

    static List<Item> _items = new List<Item>(); // 가상의 인벤토리처럼 아이템 목록 역할을 하는 리스트

    delegate bool ItemSelector(Item item); // 인자로 받은 아이템이 유효한지 아닌지 여부를 리턴해줌

    static bool IsWeapon(Item item)
    {
        return item.ItemType == ItemType.Weapon; // 아이템 타입이 weapon인 경우 true 리턴
    }

    static Item FindItem(ItemSelector selector) // 찾을 아이템의 조건을 매개변수로 넘겨줌
    {
        // 아이템을 쭉 스캔하면서 찾으려는 무기가 있는지 보고, 있으면 바로 그 아이템을 반환
        foreach (Item item in _items)
        {
            if (selector(item)) // 만약에 selector의 조건을 아이템이 통과한다면
                return item; // 아이템을 찾았다는 뜻이므로 리턴해줌
        }
        return null; // 반환이 안되면 조건에 맞는 아이템이 없다는 뜻이므로 null 리턴
    }

    static void Main(string[] args)
    {
        _items.Add(new Item() { ItemType = ItemType.Weapon, Rarity = Rarity.Normal }); // 아이템을 만들면서 동시에 정보를 넣어 ADD 해주는 문법
        _items.Add(new Item() { ItemType = ItemType.Armor, Rarity = Rarity.Uncommon });
        _items.Add(new Item() { ItemType = ItemType.Ring, Rarity = Rarity.Rare });

        Item item = FindItem(IsWeapon);
    }

    /*
     * 여기까지 구현하면 이전 버전에 비해 앞으로 추가할 함수의 양이 확 줄어든다는 장점이 생김.
     * 하지만 그럼에도 아쉬운 점이 있음.
     * 
     * 만약 조건이 20개가 필요하면 IsWeapon과 같은 조건 함수가 20개 필요해지는 것이다.
     * 즉, 불필요하게 코드가 늘어나는 것은 똑같다.
     * 
     * 이 문제를 해결하기 위해서 조건함수를 익명 함수로 처리해보자.
     */

 

3. 익명 함수(=무명 함수)를 이용한 구현

    static void Main(string[] args)
    {
        _items.Add(new Item() { ItemType = ItemType.Weapon, Rarity = Rarity.Normal }); // 아이템을 만들면서 동시에 정보를 넣어 ADD 해주는 문법
        _items.Add(new Item() { ItemType = ItemType.Armor, Rarity = Rarity.Uncommon });
        _items.Add(new Item() { ItemType = ItemType.Ring, Rarity = Rarity.Rare });

        Item item = FindItem(delegate (Item item) { return item.ItemType == ItemType.Weapon; });
    }

    /*
     * IsWeapon 함수를 지우고 1회용 함수 구현을 위해서 익명 함수(=무명 함수, (Anonymous function))를 사용함.
     * 델리게이트를 사용한 2번 코드와 동일한 결과이지만 코드가 더욱 깔끔하게 간략화되었음.
     * 다른 데서도 필요한 함수라면 델리게이트로 만들어야겠지만 1회성 함수라면 이런식으로 처리하는 것이 좋음.
     */

 

4. 익명 함수보다 더 간략화 된 람다식

    static void Main(string[] args)
    {
        _items.Add(new Item() { ItemType = ItemType.Weapon, Rarity = Rarity.Normal });
        _items.Add(new Item() { ItemType = ItemType.Armor, Rarity = Rarity.Uncommon });
        _items.Add(new Item() { ItemType = ItemType.Ring, Rarity = Rarity.Rare });

        // 익명함수 대신 람다로 처리함.  ::: ((입력되는 값) => {반환할 값}); :::
        Item item = FindItem((Item item) => { return item.ItemType == ItemType.Weapon; });
    }

 

5. 일회용 함수 구현 외에도 람다식을 활용하는 법 (람다식 재사용)

ItemSelector selector = new ItemSelector((Item item) => { return item.ItemType == ItemType.Weapon; });

// 이렇게 설정해주면 람다식을 재사용 할 수 있음.
Item item = FindItem(selector);

 

+) 람다식 주제에서 살짝 벗어났기 때문에 추후 델리게이트 항목으로 이동

 

6. 공용화 된 델리게이트 사용

// <기존 델리게이트>
delegate bool ItemSelector(Item item);

// <공용화된 델리게이트>
delegate Return MyFunc<T, Return>(T item); 
// + 델리게이트도 특정 타입이 아닌 일반화된 <T> 자료형을 사용해서 나타낼 수 있다.

이렇게 델리게이트를 선언해놓으면 앞으로 반환 형식 1개, 입력 형식 1개가 있는 타입의 델리게이트는

모두 MyFunc 를 이용해 넘겨 줄 수 있다.

 

아래는 사용 예시이다.

    static List<Item> _items = new List<Item>(); // 가상의 인벤토리처럼 아이템 목록 역할을 하는 리스트

    delegate Return MyFunc<T, Return>(T item);

    static Item FindItem(MyFunc<Item, bool> selector)
    {
        // 아이템을 쭉 스캔하면서 찾으려는 무기가 있는지 보고, 있으면 바로 그 아이템을 반환
        foreach (Item item in _items)
        {
            if (selector(item)) // 만약에 selector의 조건을 아이템이 통과한다면
                return item; // 아이템을 찾았다는 뜻이므로 리턴해줌
        }
        return null; // 반환이 안되면 조건에 맞는 아이템이 없다는 뜻이므로 null 리턴
    }

    static void Main(string[] args)
    {
        _items.Add(new Item() { ItemType = ItemType.Weapon, Rarity = Rarity.Normal }); // 아이템을 만들면서 동시에 정보를 넣어 ADD 해주는 문법
        _items.Add(new Item() { ItemType = ItemType.Armor, Rarity = Rarity.Uncommon });
        _items.Add(new Item() { ItemType = ItemType.Ring, Rarity = Rarity.Rare });

        MyFunc<Item, bool> selector = new MyFunc<Item, bool>((Item item) => { return item.ItemType == ItemType.Weapon; });
		//								-> 여기서 MyFunc 앞의 new는 없어도 똑같이 동작한다.

        // 이렇게 설정해주면 람다식을 재사용 할 수 있음. 
        Item item = FindItem(selector);
    }

 

+) 심화

// <심화>
// 위의 상태로는 인자를 1개 밖에 받을 수 없으므로, 
// 인자가 여러개 필요하다면 아래의 예시처럼 필요한 인자의 갯수만큼 선언 해두면 된다.

delegate Return MyFunc<Return>(); // 반환 형식은 있고, 입력 형식은 필요가 없는 타입의 경우
delegate Return MyFunc<T1, T2, Return>(T1 t1, T2 t2); // 반환이 1개 있고, 인자가 2개 필요한 타입의 경우

// 하지만 c#에는 위에 나와있는 내용처럼 인자 수에 따른 Func<>가 16개 미리 만들어져 있다.
// 즉, 따로 델리게이트를 직접 선언하지 않아도 Func<>를 사용하면 된다.

// 주의할 점은 Func<>는 out으로 TResult가 있는 타입이기 때문에, 
// 만약 void 형식으로 리턴하는 함수가 필요하다면 Action<> 타입 중 하나를 사용해야 한다.

// <결론>
// 반환 타입이 있을 경우 Func를
// 반환 타입이 없을 경우 Action을 사용한다.

// EX) Func<Item, bool> selector = (Item item) => { return item.ItemType == ItemType.Weapon; };
// Item을 인자로 받고, boolean을 반환하기 때문에 Func를 사용함.

 

[정리]

  • 람다식은 익명 함수의 선언을 하나씩 하지 않고 빠르게 만들 수 있도록 해주는 문법이다.
  • 람다식은 1회용 함수 구현에 활용될 수 있다.

 

'C#' 카테고리의 다른 글

220817 Interface (인터페이스)  (0) 2022.08.17
[C#] 일반화(Generic)  (0) 2022.08.16
220816 배열과 컬렉션  (0) 2022.08.16
220718 C# 연습문제 풀이  (0) 2022.07.18
[C#] 문법 정리  (0) 2022.07.17
카테고리
작성일
2022. 7. 15. 15:50
작성자
risehyun

· 오늘 공부한 내용

오늘은 어제 정리한 내용들을 바탕으로 설계를 수정하기 위해서 코드를 리뷰해보았다.

우선적으로 수정해야 할 것으로 보이는 부분이 크게 2가지가 있었는데, Task를 등록하는 과정과 블랙보드 참조 및 사용에 대한 부분이다.

 

1. Task 등록 부분

현재 구현된 내용상 모든 노드들은 공통적으로 base node를 상속받고, 크게 평가 함수 부분의 내용만 달라지도록 구현이 되어있다. 그런데 코드 리뷰를 하다보니 여기서 문제점이라고 할까, 눈에 밟히는 부분이 있었다.

리프 노드(Task)들의 수가 많아지면 상속을 받는 클래스들이 불필요하게 너무 많아진다는 점이다.

생각을 정리해 본 결과 이 기능을 구현하자면 크게 2가지 방법으로 처리할 수 있을 것 같다.

 

  •  클래스 상속으로 task 정의(복잡한 구현, 참조가 필요한 경우에 유용하다) -> 현재 구현 방식

  • 이벤트 등록 방식으로 task 추가(등록시에 필요한 참조 변수 같이 넘겨줘서 작동하는 방식으로) -> 대안으로 생각한 방식


사용자의 입장에서는 이 부분을 이벤트로도 처리하면 더욱 간단할 것 같다.

게다가 복잡한 Task가 아닌 이상 평가 부분에서 사용하는 변수 외에는 크게 달라지는 내용이 없으므로...

 

그래서 다시 생각해본 결과, 상속 방식과 이벤트 함수만 재정의하는 방식 둘 다 지원하도록 설계를 변경해야 할 것 같다.

 

대략적으로 구상된 수정 방법은 다음과 같다.

 

  • 외부에서도 태스크 이벤트를 사용할 수 있도록 하는 기능을 평가 함수 안에 구현하고 이벤트가 정의 되어 있으면 호출하도록 한다.
  • 이벤트 등록 기능은 Base node에서 구현하고, 태스크에서 호출가능하게끔 세팅한다. 즉, 자식 노드에서는 평가 함수에 기본적인 자신의 기능을 가지고 있고, 추가로 이벤트 함수가 정의되어있는지 확인하고 정의가 되어 있다면 해당 이벤트를 호출하는 기능을 추가하면 된다.
  • 이 방식에서 고민이 되는 점이 있는데, 자식 노드에서 우선 부모 node(이벤트 호출만 해줌) 쪽 함수를 먼저 호출하고 자신의 함수를 이어서 호출하게 할지 / 반대로 자기 자신 것을 호출하고, 부모 함수를 이어서 호출하도록 할지다. 이렇게 되면 먼저 자신의 노드를 평가하고 이벤트를 호출하게 된다. 순서로 따지자면 다르지만 지금 당장 생각하기에 결과적으로 큰 차이가 있을 것 같지는 않다. 해서 일단은 후자쪽으로 수정 작업을 진행할 계획이다.

 

2. 블랙보드 참조 및 이용

현재 블랙보드는 글로벌과 로컬 클래스가 서로 나누어져 있다.

그런데 언리얼 비헤이비어트리를 다시 살펴보니 아무래도 로컬 변수를 사용하는 게 일반적인 것으로 보인다.

따라서 클래스를 따로 분리하지 않고 로컬 변수를 기본으로 사용하되, AI관련 Main 스크립트에서 static으로 블랙보드 변수를 선언해 글로벌 변수로 사용하면 될 것 같다.

 

이를 위한 밑작업을 해야 하는데

class BehaviourTree
{
	private Node root;
    
    public BehaviourTree(Node root)
    {
    	this.root = root;
    }
    
    public IEnumerator RunBT()
    {
    	while (true)
        {
        	root.Evaluate();
            yield return new WaitForSeconds(0.5f);
        }
    }
}

 

수정하게 되면 현재 작성된 생성자 부분에서 root.SetTree(this); 을 추가로 작성해 처리 해주고 이 SetTree() 함수를 이용해서 모든 노드가 동일한 블랙보드를 참조할 수 있는 일종의 링크를 만들고자 한다.

이렇게 하면 나머지 노드들은 getTree() 함수를 통해서 최상위 Root가 어디 있는지 바로 알 수 있고, 이를 이용해서 Root에 할당 되어 있는 블랙보드를 알 수 있을 것이다.

 

또 해당 부분 외에 Node에서 데이터를 Get/Set 하는 부분이 최근에 작성한 블랙보드와 호환되지 않아 추가 수정하기로 했다.

 

· 어려웠던 내용

밑바닥에서부터 헤딩해가며 만들다보니 리뷰를 할 때마다 다양한 문제가 계속 쏟아진다. 이걸 모두 해결하고자하면 정말 많은 시간이 필요할테니 적당히 타협해서 작업해야하는 데.. 설계엔 답이 없다! 라고 생각하지만 약간 막막한 것은 사실이다. 그 점 외에 다른 고민은 없었다.

 

· 궁금한 내용과 부족한 내용

현재 생각해둔 설계에서는 데코레이터 부분을 사용하지 않아도 시퀀스로 로직을 처리할 수 있는데, 설계를 바꾸면서 다시 확인해보니 아무래도 병렬처리 부분이나 조건에 대한 부분이 앞으로 필요하지 않을까 하는 생각이 든다. 이 점을 어떻게 처리하면 좋을까? 언리얼 비헤이비어 트리를 레퍼런스 삼고는 있지만 다른 좋은 방법이 없을지 궁금하다. 관련 자료를 더 찾아볼까..

 

· 느낀점 또는 목표

오늘은 전반적인 리뷰를 했으니 남은 한 주동안 내용을 수정하고, 어느정도 작동 되는 것을 확인한 뒤에 다시 설계를 살펴보아야겠다. 해당 기능에 대한 구현 목표는 최대 다다음주까지 완성된 기능 데모를 만드는 것이고, 이 부분까지 완성되면 비쥬얼 스크립팅을 대신할 Json 활용 트리 정의에 대해 추가적으로 고민하고 적용해볼 것이다.

 

'TIL' 카테고리의 다른 글

23-05-11  (0) 2023.05.11
23-05-10  (0) 2023.05.10
23-04-05 TIL  (0) 2023.04.05
220802 디버깅  (0) 2022.08.02
220714 비헤이비어 트리 (Behavior Tree, BT) 정리  (0) 2022.07.14
카테고리
작성일
2022. 7. 14. 23:33
작성자
risehyun

정보 전달성 포스팅이라기 보다는 TIL 기록 겸 설계를 위한 생각 정리용 포스팅이 되겠다.

 

현재 유니티에서 BT를 구현하고 있는데, 프로토타입 기본 기능까지는 완성했으나 응용에 여러 어려움이 있어 오히려 구현 과정이 복잡해지는 느낌이다.

특히 변수 처리. 트리를 만들 때마다 정의한 task 내부에서 변수를 처리하고 있는데 이 부분이 비효율적이라 석연치 않다.

근본 문제가 무엇인가 고민해보니 기능 구현에 급급해 일반화를 하지 않아서 라는 결론이 나왔다.

따라서 우선 목표를 다음과 같이 잡고 프레임워크를 수정하려 한다.

 

개발 목표

 - 유니티로 구현하지만 Base node들은 최대한 엔진에 종속적이지 않게 설계할 것

 - 혼자 구현하고 활용하는데 그치지 않고, 타인도 사용하는 코드라고 생각하며 확장성과 일반화를 고려할 것

 

나에게 친숙한 것은 언리얼에서 사용되는 BT라 이번 포스팅에서는 이것과 일반 비헤이비어 트리의 기본적인 사항을 다시 정리하고 해당 내용을 반영해 설계를 손 볼 것이다.

 

참고한 자료는 언리얼 공식 문서와 이득우의 언리얼 C++ 게임 개발의 정석 등이다.

 

https://docs.unrealengine.com/5.0/ko/behavior-tree-in-unreal-engine---quick-start-guide/

 

비헤이비어 트리 퀵 스타트 가이드

이 가이드에서는 순찰하거나 플레이어를 추격하는 AI 캐릭터를 비헤이비어 트리로 구성하는 방법을 알아봅니다.

docs.unrealengine.com

 


· 오늘 공부한 내용

비헤이비어 트리의 예시

 

비헤이비어 트리(Behavior Tree, BT)

- AI가 해야 할 행동을 분석하고 우선순위가 높은 행동부터 실행할 수 있도록 트리 구조로 설계하는 기법

- 내부에 구현된 로직을 실행함

 

특징과 사용시 장점

- 가질 수 있는 노드와 자식 수에 제약이 없다. 그렇기 때문에, 확장이 자유롭고 복잡한 AI를 표현하는데 적합하다

- Task(비헤이비어 트리가 수행할 액션)를 재사용하여 중복 코드를 줄일 수 있으며 빠르고 간편하게 개발할 수 있다.

 

비헤이비어 트리의 기본 구조

- 잠재적 복잡도로 인해 대부분 비동기로 구현된다.

- 계층 구조 가장 말단에 있는 노드를 리프(Leaf) 노드라고 하며, 언리얼 비헤이비어 트리에서는 Task라고 한다. 각 Task Node는 행동 또는 테스트 등을 표현할 수 있다.

- 트리 최상위의 Root 노드에서부터 평가를 시작하여 자식 노드를 타고 이동하면서 원하는 조건이 만족 될 때까지, 또는 가장 마지막 노드에 도달할 때까지 순회한다.

- 비헤이비어 트리의 각 노드는 항상 다음 3개의 상태 중 하나를 반환한다.

  • Success : 노드가 조건을 만족할 때
  • Failure : 노드가 조건을 만족하지 않을 때
  • Running : 노드에서 검사하는 조건의 유효성이 정의되지 않았을 때, 즉 현재 노드의 평가가 진행 중일 때를 의미
    ▶ 노드 평가  과정이 필요에 따라 수 프레임간 진행 될 수 있고, 다수의 에이전트에 대한 평가가 이루어질 수도 있기 때문에 결가가 나오기까지 무한정 기다릴 경우 성능에 영향을 끼칠 수 있다. 이러한 문제를 방지하기 위해 해당 상태가 존재하는 것이다.

+) 언리얼에서는 상술한 부분을 기반으로 아래의 상태들을 사용한다.

  • Succeeded : 테스크를 성공적으로 수행했다.
  • Failed : 태스크를 수행했지만 실패했다.
  • Aborted: 태스크 실행 중에 중단됐다. 결과적으로 실패했다.
  • InProgress : 태스크를 계속 수행하고 있다. 태스크의 실행 결과는 향후 알려줄 예정이다.

 

합성노드 (Composite, 컴포짓)

- 하나 이상의 자식을 가지는 노드

- 루트 노드에는 이 컴포짓 노드만 어태치할 수 있다.

- 합성 노드의 상태는 자식들의 평가 결과로 결정되며, 자식들의 평가가 진행 중일 때는 Running 상태가 된다.

- 합성 노드의 형태는 자식의 평가 방식에 따라 나뉘며 기본적으로 시퀀스와 셀렉터, 2개의 종류가 있다. 언리얼 비헤이비어트리에는 추가로 단순 병렬이라는 1개의 노드가 더 존재한다.

  • 시퀀스(Sequence) : 일종의 AND 연산자. 왼쪽에서부터 오른쪽으로의 깊이 우선 탐색을 실시한다. 이때 전체 자식이 모두 조건을 만족할 때만 성공으로 인정한다. 반대로 한 자식이라도 조건을 만족하지 못하면 실패한다. 실패하는 노드에 도달 할 때까지 자식을 계속해서 실행시키며, 이러한 특성 때문에 일반적으로 복수의 자식을 순서대로 실행시키는데 활용된다.

    EX) 몬스터의 정찰 패턴. 타겟이 시야 안에 없을 때 잠시 기다렸다가(Wait) -> 순찰 위치를 선택하고(SetPatrolPos) -> 해당 위치로 이동(Move To)하는 일련의 동작들을 순서대로 실행

    EX) 몬스터의 공격 패턴. 플레이어를 향해 움직여 일정 거리 안에 들어왔는지 확인(1)한 다음 회전(2)하여 공격(3)하는 기능이라고 할 때, 플레이어가 일정 거리 안에 들어왔는지 확인하는 (1)번 자식이 실패하면, 이후 순서로 존재하는 (2)번과 (3)번 자식에 할당된 기능은 실행되지 않는다.

  • 셀렉터(Selector) : 일종의 OR 연산자. 시퀀스와 동일하게 왼쪽에서부터 오른쪽으로의 깊이 우선 탐색을 실시한다. 시퀀스와 다른 점은 실행 시에 자식 노드 중 하나라도 조건을 만족하면 성공으로 인정하며, 실패에 도달할 때까지 계속 자식을 실행시키는 시퀀스와 달리 나머지 자식들을 더 이상 검사하지 않고 즉시 성공을 보고한다. 반대로 모든 자식이 조건 검사를 만족하지 못하면 실패를 보고한다. 이러한 특성 때문에 복수의 자식 중에 하나를 선택하는데 사용한다.

    EX) 몬스터의 공격 패턴. 타겟이 시야에 있을 때 공격 가능 범위 안에 해당 타겟이 있는지 유무에 따라 공격을 하는 기능을 구현할 때, 셀렉터를 이용하면 공격을 한다(Can Attack)와 공격 하지 않는다(Can Not Attack)라는 2개의 상태 중 하나를 선택하여 실행할 수 있다.

    EX) 몬스터의 추격 패턴. AI가 플레이어를 추격하는 기능이 적용된 자식 노드가 성공적으로 실행 중이라면, 해당 노드의 실행이 완료될 때까지 계속 해당 노드에 머물러 있다가, 완료되면 셀렉터의 부모 노드로 이동하여 다음 동작을 결정하는 흐름을 계속 진행하게 된다.

서브 노드 (데코레이터 노드)

- 합성 노드와 달리 단 하나의 자식 노드를 가진다는 차이가 있다.

- 노드 자체에 조건을 두고 실행하는 것과 결과적으로는 동일한 기능이지만 차이가 있다. 데코레이터 노드는 근본적으로 자식이 반환한 상태를 취하고, 스스로 매개변수에 기반하여 반응한다. 또한 자식이 평가되는 방식과 횟수를 직접 결정할 수도 있다.

- 데코레이터 노드의 종류는 다음과 같다.

  • 인버터(Inverter)
    - 일종의 Not 수정자이다. 자식이 반환한 상태의 반대를 취한다. 따라서 자식이 True를 반환했을 때의 데코레이터 평가는 False가 되며, C#에서 bool 변수 앞에 ! 오퍼레이터를 붙인 것과 동일한 결과가 나온다.
  • 리미터(Limiter)
    - 에이전트가 무한 루프에 빠지지 않도록 평가 횟수를 제한하는 기능을 가진다. 이 데코레이터에서는 리피터와 반대로 조건이 아닌 횟수를 제한할 때 유용하게 사용된다.

    EX) 포기할 때 까지 문을 걷어차는 횟수

  • 데코레이터(Decorator)
    - 언리얼 비헤이비어 트리의 파란색 노드가 바로 이 데코레이터다. 일반적인 BT 시스템에서는 조건식이라고도 한다.

    - 일반적인 BT에서는 데코레이터에서 정의된 대로 True 또는 False가 될 때까지 정해진 횟수(또는 무한대)만큼 반복하면서 노드를 평가한다.

    EX) "에너지가 충분히 참" 상태가 될 때까지 계속해서 조건을 검사하다가 성립될 때 공격을 시작하는 기능 구현 가능

    - 언리얼에서 이 노드는 컴포짓 노드와 태스크 노드에 어태치(일반적으로는 컴포짓에서 많이 쓰임)되어 블랙보드 키가 True인지 검증하는데 사용되며, 이를 통해 트리의 분기나 단일 노드의 실행 여부를 결정하는 조건식으로 쓰인다. 정해진 횟수만큼 반복하여 노드를 평가하는 기능은 언리얼에서 서비스(Service) 기능으로 별도 구현한 것으로 보인다.


    EX) 블랙보드 키 값을 결정하고 이것이 유효한 경우 해당 분기를 실행할 수 있도록 구성할 수 있음

  • 언리얼에서 제공하는 기본 데코레이터 종류와 세부사항에 대해서는 하단의 문서를 참고

 

https://docs.unrealengine.com/5.0/ko/unreal-engine-behavior-tree-node-reference-decorators/

 

비헤이비어 트리 노드 레퍼런스: 데코레이터

비헤이비어 트리 데코레이터 노드의 레퍼런스 정보입니다.

docs.unrealengine.com

  • 데코레이터 노드들은 다음처럼 디버깅과 테스트 용도로도 사용할 수 있다.
    - 가짜 상태 : 데코레이터에 정의된 대로 항상 참이나 거짓을 반환하여 에이전트의 특정 행동을 검증하고자 할 때 사용한다. 또는 주변의 다른 에이전트를 관찰하기 위해서 가짜로 Running 상태를 유지시킬 수도 있다.

    - 브레이크 포인트 : 코드에서의 브레이크포인트와 같다. 이 노드에 도달하면 디버그 노드나 다른 메소드를 통해 알림을 받게끔 설정할 수 있다.

  • 데코레이터 노드들은 상호 배타적인 단일체 원형이 아니기 때문에 각자의 목적에 맡게 조합할 수 있으나 너무 많은 기능을 하나의 데코레이터에 결합하는 것은 지양해야 한다. 이 경우에는 효율성과 편의성 측면에서는 시퀀스 노드를 사용하는 쪽이 더 나을 수도 있기 때문이다.

  • 서비스(Service)
    - 이 노드는 다른 비헤이비어 트리 시스템에서의 전통적인 병렬(Parallel) 노드를 대체하기 위해 언리얼 엔진에서 활용하는 특수 노드이다.

    - 태스크(Task) 및 셀렉터, 시퀀스, 단순 병렬을 포함한 컴포짓(Composite) 노드와(이쪽이 일반적) 태스크 노드에 어태치하여 자신의 분기가 실행되는 동안 정의된 주기만큼 실행된다. 지정된 시간(초)마다 콜백을 등록하고 주기적으로 발생시킬 필요가 있는 다양한 유형의 업데이트를 수행할 수 있으며 보통 블랙보드의 확인 및 업데이트에 사용된다.

    - 디폴트로 사용할 수 있는 서비스가 기본으로 제공되며, 커스텀 서비스를 생성할 수 있으며 이러한 서비스를 이용해 비헤이비어 트리 의 실행 방식을 결정하는 데 보조적으로 사용하는 경우가 많다.

    EX)  AI 폰이 현재의 적을 쫓는 비헤이비어 트리를 정상적으로 따라가는 동안 어느 적이 최적의 대상인지 결정하기 위해 서비스를 사용할 수 있음

Detect 서비스의 세부 구조

커스텀 노드로 Detect 기능을 작성할 수 있고 이에 따른 세부 구조는 다음과 같다.

- Interval : 틱 간격으로, 설정된 시간 간격만큼 서비스의 틱이 실행된다.

- Random Deviation : 랜덤 편차로, 틱 간격에 임의의 랜덤 범위를 적용한다.

단순 병렬 노드
단순 병렬 노드의 디테일 패널 구성

  • 단순 병렬(Simple Parallel, 심플 페러렐) : 단순 병렬 처리를 위한 언리얼 비헤이비어 트리의 추가 컴포짓 노드로, 자손을 두 개만 갖는다. 한 자손은 반드시 단일 태스크 노드여야 하며(데코레이터 선택 가능), 나머지 자손은 완전한 서브트리일 수 있다. 이를 통해 전체 노드 트리와 함께 하나의 메인 태스크를 실행할 수 있다. '적을 공격하는 동안 적을 향해 이동' 처럼 'A를 수행하는 동안 B도 수행한다'와 같은 식이다. 이때  A는 메인 태스크이고, B는 A가 완료되기까지 기다리는 도중의 부가 태스크 또는 필러 태스크이다.
  • 부가 태스크(태스크 B)를 처리하는 방법에는 몇 가지 옵션이 있지만, 노드 개념은 전통적인 병렬 노드에 비해 비교적 단순하다. 그러면서도 병렬 노드가 사용되는 일반적인 경우를 대부분 지원한다. 단순 병렬 노드를 사용하면 이벤트 주도형 최적화를 활용하기 쉽지만, 완전 병렬 노드는 최적화하기 훨씬 까다롭다.
  • Finish Mode에 따라 메인 테스크의 실행이 완료되었을 때 백그라운드 트리가 처리되는 방식을 선택할 수 있다. Immediate를 선택하면 메인 테스크 종료 즉시 바로 백그라운드 트리를 중단하며, Delayed를 선택하면 메인 테스크가 종료된 후 백그라운드 트리가 끝날 때까지 기다린다. 이러한 특징 때문에 동시에 2가지 작업을 하도록 병렬 실행을 설정하는 데 활용된다.

Task(= 리프 노드)

- AI가 수행할 수 있는 액션을 의미하며 트리의 가장 말단에 위치한다. 언리얼에서 보라색으로 표시되는 노드이다.

 

- 리프 노드는 어떤 종류든 제약 없이 행동을 지정할 수 있으며 에이전트가 가질 수 있는 모든 로직을 표현할 수 있다. 단지 계층의 가장 마지막 노드라는 특징만 있으며 후술할 3개의 상태 중에 하나의 상태만 반환할 수 있다.

 

- 리프 노드는 독립적으로 실행될 수 없고 반드시 합성 노드를 거쳐 실행되어야 한다.

 

- 언리얼에서 제공하는 기본 태스크의 종류와 세부사항에 대해서는 다음 문서를 참고

https://docs.unrealengine.com/5.0/ko/unreal-engine-behavior-tree-node-reference-tasks/

 

비헤이비어 트리 노드 레퍼런스: 태스크

비헤이비어 트리 태스크 노드의 레퍼런스입니다.

docs.unrealengine.com

 

  • 현재 작업하고 있는 Task인 Move To의 작동 방식에 대한 의문점이 있어 언리얼 공식 문서 외의 자료들을 찾아보았다. 아래 포스트가 궁금했던 내용에 대해 잘 정리되어 있어 링크를 첨부한다.
    https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=raveneer&logNo=220796853517 
  • 참고로 언리얼의 네비게이션 시스템이 목적지로 폰을 이동시킬 때는 SimpleMoveToLocation 함수를 사용한다고 한다.
  • Move To 태스크에서 사용하는 이동 함수가 AI Move To인지 SimpleMoveToLocation 인지 모르겠다. 관련 내용을 더 찾아보도록 해야지.. 했는데 아래의 영상을 찾았다.
  • https://www.youtube.com/watch?v=JrgETMo6jko
  • 타겟으로 지정된 Player 자체를 추적(location이 아니라) 하며 허용 반경에 들어가 태스크가 성공 상태가 되어 종료하더라도 BT 구조상 다시 상위 노드로 이동하기 때문에 플레이어 위치가 갱신되면 갱신된 위치로 다시 이동하게 된다. 즉, 계속해서 위치가 갱신된 플레이어를 따라 간다.

 

언리얼에서의 사용

- 언리얼에서는 BT와 블랙보드를 함께 활용함

 

- 언리얼 사용 시 일반적으로 요구되는 선행 작업은 AI 컨트롤러와 NavMesh의 적용(이동 관련 로직을 적용할 경우)이며, 이후 블랙보드 생성과 비헤이비어 트리를 구현한다. C++로 개발할 때에도 캐릭터 에 적용할 AI 컨트롤러 클래스 안에 비헤이비어 트리와 블랙보드를 할당하여 사용한다. 이때 비헤이비어 트리가 AI 컨트롤러에서는 컨트롤러가 폰에 '빙의'할 때 비헤이비어 트리를 실행한다.

 

- 언리얼 비헤이비어 트리는 총 3개의 패널로 구성되어 있다.

  • 비헤이비어 트리 그래프 : 실제 분기와 노드의 레이아웃을 포함한 Tree 구현
  • 디테일 : 각 노드의 프로퍼티를 정의
  • 블랙보드 : 게임이 실행 중일 때 블랙 보드 키와 현재 값을 확인할 수 있는 디버깅 용 패널

- 커스텀 태스크, 데코레이터, 서비스를 만들 수 있다.

 

블랙보드

- 인공지능의 판단에 따라 사용하는 데이터 집합으로 여러 사용자 정의 키(Key, 모니터링 할 변수) 를 추가하고 트래킹할 수 있으며, NPC의 의사 결정은 이 블랙보드에 있는 데이터를 기반으로 진행됨

- Root의 디테일 부분에서 해당 트리에서 활용할 블랙보드를 선택할 수 있음

- 세부적인 구조는 다음과 같이 구성됨

  • Key의 이름 : 사용자가 정의한 키 이름
  • 설명 : 해당 블랙보드 키에 대한 설명을 적을 수 있다. 필수요소X, 개발 편의를 위한 기능
  • Key 타입 : 키 타입(Key Type) 키를 생성할 때 정의되며, 지원하는 타입은 Bool, Class, Enum, Float, Int, Name, Object, Rotator, String, Vector가 있다. 오브젝트(Object) 및 클래스(Class) 키는 특정 클래스를 정의하는 추가적인 옵션을 제공하며 어떤 베이스 액터 클래스를 사용할지 정의할 수있다. 또한 열겨형은 열거형 타입(사용하도록 할당된 열거형), 열거형 이름(C++ 코드로 정의된 열거형 이름으로, 열거형 타입 에 할당된 에셋보다 우선함), 열거형 이름 유효 여부(열거형 이름 오버라이드가 유효하고 가능하며 활성화된 경우 설정)으로 구성되어 있다.
  • 타입의 Base Class
  • 동기화된 인스턴스(Instance Synced) : True로 설정 시 해당 키는 이 블랙보드의 모든 인스턴스에서 동기화되어 같은 값을 가진다.
  • Parent

  • BT에서 사용할 변수 문제를 이것으로 해결할 수 있을 것으로 보인다!
  • 처음엔 단순하게 글로벌 변수들의 집합인가 했는데 어떤 경우에는 로컬 변수로 사용될 때가 있어서, 내부에서 기능적으로 이 둘을 구별하는 것이 명확해보임.
  • 구현 시 클래스 내부에 변수를 Get/Set하는 부분과 디버그를 위한 테이블 값 출력 기능 필요
  • 다양한 타입의 변수를 저장해야하니 hashtable을 사용하는 것이 적합하다고 생각했으나, 딕셔너리로 구성해서, Object 타입으로 변수들을 저장하는 쪽이 더 나은 것이 아닌가 고민이 된다. 기본적으로 task에 사용될 트랜스폼 정보라던지.. 다양한 변수들이 Object로 저장된 객체 하위에 있을 것이므로. 이 부분은 더 생각해보자

 

AI 인지 컴포넌트(AIPerception Component)

AI 인지 컴포넌트의 세부 구조

https://docs.unrealengine.com/4.27/ko/Basics/Components/AI/

 

AI 컴포넌트

AI 인지에 사용되는 AI 관련 컴포넌트 및 폰 감각에 대한 설명입니다.

docs.unrealengine.com

https://docs.unrealengine.com/5.0/ko/ai-perception-in-unreal-engine/

 

AI 퍼셉션

이 문서에서는 AI 퍼셉션 컴포넌트를 소개하고 이를 사용하여 AI 인지 기능을 생성하는 방법에 대해 설명합니다.

docs.unrealengine.com

 

언리얼 비헤이비어 트리의 차이점

1. 이벤트 주도형 비헤이비어 트리

- 기존 BT 시스템과 달리 언리얼 비헤이비어 트리는 이벤트 주도형이다. 즉 프레임마다 불필요한 작업을 하지 않는다. 관련 변경 사항이 발생했는지는 계속해서 체크하지만, 트리 자체는 트리 내부의 변경 사항을 트리거 할 수 있는 '이벤트'를 수동적으로 리스닝한다. 추기적인 반복 작업이 발생하지 않기 때문에 퍼모먼스와 디버깅이 모두 개선된다. 디버깅 시에는 트리 내의 실행 위치 또는 블랙보드 값의 변경만 고려하면 된다.

 

2. 조건문이 리프 노드가 아님

- 표준 BT에서 조건문은 Task 리프 노드에 해당되며, 성공과 실패 이외에는 아무것도 하지 않는다. 반면 언리얼 비헤이비어 트리에서 조건문은 데코레이터를 사용해 처리한다. 자신이 제어하는 서브트리의 루트에 다음과 같이 조건문을 붙여주는 것이다.

파란색 노드가 서브트리의 루트에 붙여준 데코레이터 조건문이다.

 

이와 같이 구현된 이유는 다음과 같은 이점 때문이다.

  • 기존 모델에서는 조건문이 리프 사이에 있으므로 어떤 리프가 조건문이고 어떤 리프가 액션인지 알아내는 데 시간이 걸린다. 반면 조건문 데코레이터는 비헤이비어 트리 UI를 보다 직관적이고 읽기 쉽게 만들어 준다. 
  • 모든 리프가 액션 태스크이므로 트리를 통해 실제 어떤 액션이 지시되는지 알기가 더 쉽다.
  • 트리 내 중요 노드에서 이벤트를 기다리는 관찰자 역할로 만들기가 쉽다. 이러한 속성은 트리의 이벤트 주도형 속성을 활용하는데 중요한 영향을 끼친다. 예를 들어서, 위에 첨부된 사진에서는 충분히 가까움(Close Enough) 과 블랙보드 데코레이터가 시퀀스 노드의 자손이 실행되는 것을 방지한다.

 

3. 동시 발생 비헤이비어

- 표준 BT는 일반적으로 병렬 컴포짓 노드를 사용해 동시 발생 행동을 처리하며, 이 병렬 노드들이 모든 자손에서 동시에 실행되기 시작한다. 그렇기 때문에 자손 트리 중에서 하나 이상이 종료되었을 때 어떤 액션을 취할지는 원하는 행동에 따라 특수 규칙으로 결정된다. 반면 언리얼 BT에서는 복잡한 병렬 노드 대신에 단순 병렬 노드와 서비스라는 특수 노드, 데코레이터의 관찰자 중단(Observer Aborts) 프로퍼티를 이용해 동일한 유형의 행동을 달성한다. 이러한 처리 방식은 다음과 같은 장점을 가진다.

 

1. 명료성 - 서비스와 단순 병렬 노드를 사용하여 읽기 쉽고 이해하기 쉬운 단순한 트리를 만들 수 있습니다. 
2. 쉬운 디버깅 - 그래프가 명료하므로 디버깅도 쉽습니다. 또한 동시 실행 경로가 더 적어서 지금 어떤 것이 실행되고 있는지 파악하기 더 쉽습니다. 
3. 더 쉬운 최적화 - 이벤트 주도형 그래프는 동시에 실행되는 서브트리가 많지 않다면 최적화하기 더 쉽습니다.

+) 생각해보아야 할 점

병렬 노드가 반드시 태스크를 동시에 실행하는 멀티 스레딩일 필요는 없습니다. 병렬 노드는 한 번에 여러 태스크를 수행하는 개념적 방식 중 하나일 뿐입니다. 흔히 같은 스레드에서 실행되고, 어떤 순서로 시작됩니다. 해당 순서는 모두 같은 프레임에 발생하는 것을 가정하기 때문에 관련이 없어야 하지만 여전히 중요한 경우도 있습니다.

 

4. 관찰자 중단

표준 병렬 노드의 흔한 용도 중 하나는 조건을 지속적으로 확인하여 요구 조건이 False가 되는 경우 태스크를 중단하는 것이다. 예를 들어 '쉭쉭거리기' 와 '달려들기' 시퀀스를 수행하는 고양이가 있다면, 쥐가 쥐구멍으로 도망치는 순간 즉시 포기하도록 하는 것이 좋다. 병렬 노드로는 쥐에게 달려들 수 있는지 확인하는 자손과 시퀀스가 수행할 자손을 가질 수 있다. 언리얼 엔진 비헤이비어 트리는 이벤트 주도형이므로 이렇게 하는 대신에 조건문 데코레이터에서 해당 값을 관찰하게 하고 필요시 중단시키는 방식으로 처리한다. 이 예시에서는 '쥐에게 달려들 수 있는가?'를 들 수 있다. 이 경우, 시퀀스의 데코레이터에서 '관찰자 중단'을 '셀프(Self)'로 설정하면 된다.

 

 

언리얼 비헤이비어 트리의 노드 세부 구조


· 어려웠던 내용

단순히 문서를 읽고 실습하는 것이 아니라 최대한 일반적인 설계 관점에서 생각하려 하니 어려운 점이 많다.

그래도 비헤이비어 트리에 대한 전반적인 내용은 이제 어느정도 숙지되었다고 느껴진다.

 

· 궁금한 내용과 부족한 내용

조사를 하다보니 NavMesh와 AI를 연결하는 부분에 대한 설계 보강이 필요해 보인다.

현재 로직에서는 임의로 설정한 waypoint를 활용했는데, 이 NavMesh를 어떻게 처리할지에 따라 추가 구현이 달라질 것 같다. 우선적으로 유니티에서 지원하는 NavMesh를 활용하는 방법을 생각 중인데, 자체적으로 만든다는 선택지도 있다. 단순히 기존 방법을 유지해도 되겠지만.. 공부가 되는 건 자체 제작쪽일 거다. 다만 이 경우에는 구현 주제의 분류가 BT가 아니라 길찾기 알고리즘 쪽으로 넘어갈 것 같아서, 당장은 BT 구현에 집중하는 것이 좋을 것 같다.

 

· 느낀점

구현하려는 내용이 단순히 언리얼 엔진에서의 비헤이비어 트리를 모작 하는 것이 아니라 내가 생각한 설계대로 (내가 지금 구현 할 수 있는 수준에서, 주어진 시간 내에) 해내려고 하니 그 부분을 늘 염두해야 한다는 점이 제일 까다롭다. 그런데 과정이 재미있다. 어렵긴 하지만 그만 두고 싶지는 않다. 코딩을 잘 하고 못하고를 떠나서 나는 이런 생각과 구현 과정 자체가 너무 흥미롭다. 차근차근 하다보면 분명 이 과정을 통해 한 단계 더 성장할 수 있을 것이라 믿는다! 내일 이어서 할 설계 수정이 기대 된다.

 

'TIL' 카테고리의 다른 글

23-05-11  (0) 2023.05.11
23-05-10  (0) 2023.05.10
23-04-05 TIL  (0) 2023.04.05
220802 디버깅  (0) 2022.08.02
220715 비헤이비어 트리 설계 변경  (0) 2022.07.15
카테고리
작성일
2022. 7. 14. 18:59
작성자
risehyun

Actor

- 월드에 배치 또는 스폰할 수 있는 오브젝트

- 언리얼에서의 콘텐츠 구성 최소 단위

- 이름, 유형(클래스 명), 트랜스폼, 프로퍼티(액터에 설정된 속성 값), 게임 로직(특정 상황에 대한 구체적인 대응 행동 명령)으로 구성되어 있음

- 생성 직후 기본적으로 아무런 기능이 없음

- AI 컨트롤러 설정 불가

- 건물 등 배경 오브젝트 등이 여기에 해당

 

Pawn

- 플레이어가 조종할 수 있는 엑터

- 플레이어 또는 AI가 빙의할 수 있는 모든 액터의 기본 클래스로 컨트롤러에서 입력을 받을 수 있는 액터

- 레벨에 존재하는 플레이어와 생물의 물리적 표현으로, 게임 세계에 실제로 보여지고 레벨과 물리적인 충돌을 하며 배치된 액터와 상호작용 함

- 탈 것, 인간형 폰 등이 여기에 해당

 

Character

- 기본적인 이동 기능과 애니메이션이 포함된 인간형 폰 유형

- 폰의 일종이기 때문에 플레이어 혹은 AI가 조종할 수 있음

- 캐릭터 무브먼트 컴포넌트(중력 반영 움직임, 걷기 외의 다양한 이동 모드 설정 가능, 멀티 플레이 환경에서 캐릭터 움직임을 자동으로 동기화하는 기능이 포함됨)를 사용한다는 점에서 폰 모델과의 차이가 있음

- 네비게이션 사용 시 이것을 활용

- 플레이어블 캐릭터가 여기에 해당