1. 비트(bit)란 ? 

컴퓨터에서 정보를 나타내는 최소 단위. 8비트 = 1바이트.



2. 비트와 2진법의 관계

초기 컴퓨터는 전기가 통하냐(1), 안통하냐(0)를 판단해서 데이터를 처리하도록 설계되었기 때문에, 0과 1만을 사용하는 2진법의 연산을 하는 것이 컴퓨터의 전통이 되어왔다. 여담으로 컴퓨터의 전원버튼의 이미지도 0과 1을 합쳐서 만들어진 것이라고 한다. 0이면 전원이 꺼진거고, 1이면 전원이 켜진 것이다.

컴퓨터 전원버튼


아무튼 1과 0으로 이루어진 2진법으로 정보를 표시하는 단위를 비트라고 이름 붙이게 되었다.


그럼 1비트로 표현할 수 있는 정보는? 

0

1

이렇게 두가지이다. 


그럼 2비트로 표현할 수 있는 정보는?

00

01

10

11

이렇게 4가지이다.


그럼 3비트로 표현할 수 있는 정보는?

000

001

010

100

011

101

110

111

이렇게 8가지이다.


위와 같이, n비트로 표현할 수 있는 정보의 가짓수는 2의 n제곱 가지이다.


3. 비트연산이란? 

한개, 또는 여러개의 비트단위 데이터를 가지고 연산을 진행하여, 원하는 결과값을 도출해내는 방법이다.



And 연산. 두 정보의 각 자릿수를 비교해서, 둘 다 1인 자리는 1로, 아닌 자릿수들은 0으로 결과값을 도출.




or연산. 두 데이터의 각 자릿수를 비교해서 둘 중 아무나 1이라면 그 자릿수를 1로 해서 결과값을 도출.




xor 연산. 두 데이터의 각 자릿수를 비교해서 두 값이 서로 다른 자릿수는 1로, 같은 경우에는 0으로 판단해서 결과값을 도출.




not 연산. 각 자릿수가 1이라면 0으로, 0이라면 1로 뒤집어서 결과값을 도출.




왼쪽 시프트 연산. << 1이라면 왼쪽으로 모든 데이터를 이동시킨다.




오른쪽 시프트 연산. >> 2라면 모든 비트의 자릿수를 오른쪽으로 2만큼 이동시킨다.




4. 비트연산의 실제 예


예를 들어, 내가 4비트짜리 정보를 가지고 책가방 내에 학용품의 유무를 표현한다고 해보자.

왼쪽부터 시작해서 책, 필통, 공책, 자 라고 해보자. 내가 책과 공책은 있는데 필통과 자는 없다고 한다면, 그것을 4비트로 표현한다면 아래와 같다.


4비트로 표현해본 내 학용품 소지 유무.



내가 공책을 가지고 있는가? 라는 것을 비트 연산을 이용해 알아내보자.



내가 공책을 가지고 있는가?




시프트 연산을 사용하는 경우도 엄청 많지만, 현실에서 많이 사용하는 경우는 바로 LED전광판이다. 

보통 지하철에서 이번역과 내리는 곳을 설명할 때 LED전광판을 사용하는데, 이때 글씨가 왼쪽으로 흐르는 것처럼 표현할 때 왼쪽 시프트 연산자를 사용하게 된다.

0은 led불이 꺼져있는 상태, 1은 켜져있는 상태이고, 시프트 연산을 이용해서 불을 한칸씩 이동시키는 것이 가장 기초적인 led전광판의 흐르는 글씨의 원리이다. 아두이노에서 led불을 사용할 때도 시프트 연산을 사용하기도 한다.




5. 유니티에서의 비트연산

int는 기본적으로 4byte 크기의 변수이기 때문에 비트로 따지자면 32비트 크기의 데이터이다. 그래서 밑의 주석에 보면 32자리의 비트숫자로 표시한걸 볼 수 있다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using UnityEngine;
 
public class Bit : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        int a = 10// 2진법으로 0000 0000 0000 0000 0000 0000 0000 1010
        int b = 7//  2진법으로 0000 0000 0000 0000 0000 0000 0000 0111
        int result = 0;
 
        result = a & b;
        Debug.Log("and 연산 : " + result); //결과는 2이다.  2진법으로 0000 0000 0000 0000 0000 0000 0000 0010
 
        result = a | b;
        Debug.Log("or 연산 : " + result); //결과는 15이다.  2진법으로 0000 0000 0000 0000 0000 0000 0000 1111
 
        result = a ^ b;
        Debug.Log("xor 연산 : " + result); //결과는 13이다.  2진법으로 0000 0000 0000 0000 0000 0000 0000 1101
 
        result = ~a;
        Debug.Log("not 연산 : " + result); //결과는 -11이다.  2진법으로 1111 1111 1111 1111 1111 1111 1111 0101
 
        result = a << 1;
        Debug.Log("left shift 연산 : " + result); //결과는 20이다. 2진법으로 0000 0000 0000 0000 0000 0000 0001 0100
 
        result = a >> 1;
        Debug.Log("left shift 연산 : " + result); //결과는 5이다.  2진법으로 0000 0000 0000 0000 0000 0000 0000 0101
    }
}
cs



not 연산에서  음수가 되는 부분은 1의 보수, 2의 보수 개념을 알아야 하는데 복잡하니까 나중에 따로 다루겠다.





위에서처럼 숫자 연산을 할때 뿐만 아니라, 유니티 내에서 비트연산, 특히 시프트 연산을 사용하는 경우가 있다. 바로 LayerMask이다.


유니티에는 레이어라는 개념이 있는데, 특정 레이어에 소속된 게임오브젝트끼리만 물리충돌이 일어나게 하거나, 카메라를 특정 레이어에 소속된 게임오브젝트만 선별해서 모니터에 그리도록 할 수 있다.

유니티의 레이어 설정


유니티의 레이어 마스크는 레이어의 정보를 비트로 판별한다. 예를 들어 위 사진의 우측상단에서

Default레이어의 레이어 마스크 : 0000

TransparentFX레이어의 레이어 마스크 : 0001

Ignore Raycast레이어의 레이어 마스크 : 0010


이런식으로 오른쪽을 시작으로 하나의 비트 자릿수를 하나의 레이어가 상징하고 있다. 마치 위의 학용품 예제에서 우측기준 두번째 자릿수를 공책이 대표하듯 말이다.

내가 Enemy레이어의 레이어 마스크를 스크립트 내에서 사용하고 싶다고 해보자. Enemy레이어의 번호는 8번이므로, 2진수로 보자면 1000 0000 레이어마스크를 사용해야하고, 그것은 시프트 연산자를 이용하면 1<<8이다.


보통 게임오브젝트를 감지하는 RayCast를 사용할 때 특정 레이어에 속한 게임오브젝트만 감지하고 싶을 때 매개변수 중에 layerMask를 넣는데, 이때 방법은 2가지이다.


1
2
3
4
5
6
7
8
9
10
11
12
private void Update()
{
    if (Physics.Raycast(transform.position, transform.forward, Mathf.Infinity, 1 << 8))
    {
        Debug.Log("enemy!(shift)");
    }
 
    if (Physics.Raycast(transform.position, transform.forward, Mathf.Infinity, LayerMask.GetMask("Enemy")))
    {
        Debug.Log("enemy!(name)");
    }
}
cs



두개의 레이캐스트 모두 Enemy를 잘 감지하는 것을 알 수 있다.

유니티에서 세이브 데이터를 저장하는 방식은 여러가지가 있다. 대표적인 것이 JSON, XML, CSV 등의 파일형식으로 저장하거나 그것들을 바이너리화 하는 것이다. 특히 JSON방식은 자바스크립트의 인기에 힘입어 많이 쓰이는 편이다. 유니티에서는 Unity5 시절부터 JsonUtility라고 하여 엔진 내에서 Json관련 라이브러리를 제공한다.


이 글에서는 JsonUtility로 클래스 객체를 직렬화 한 뒤, System.Security.Cryptography를 이용해서 다른 사람이 세이브파일을 뜯어볼 수 없도록 암호화 해보도록 하겠다.


우선 JsonUtility가 기존에 많이 쓰였던 다른 Json 라이브러리에 비해 좋은 점은 아래와 같다.

1. Vector3 변수를 그대로 저장할 수 있다.

2. 가비지 컬렉터 메모리를 최소한만 이용한다.

3. 속도가 빠르다.

(출처 : https://docs.unity3d.com/kr/current/Manual/JSONSerialization.html)


일단 저장할 데이터를 담을 클래스를 아래와 같이 만들어보겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Collections.Generic;
using UnityEngine;
 
[System.Serializable]
public class SaveData
{
    //여러가지 타입이 가능하지만, Dictionary는 안된다.
    [SerializeField] private string characterName;
    [SerializeField] private int level;
    [SerializeField] private float exp;
    [SerializeField] private List<string> itemsName;
 
    //외부 Json 라이브러리가 아닌 JsonUtility를 쓰면 Vector3도 저장할 수 있다.
    [SerializeField] private Vector3 lastPosition;
 
    //생성자
    public SaveData(string t_characterName, int t_level, float t_exp, List<string> t_itemsName, Vector3 t_lastPosition)
    {
        characterName = t_characterName;
        level = t_level;
        exp = t_exp;
 
        //일일이 값을 복사하는게 깔끔하다.
        itemsName = new List<string>();
        foreach(var n in t_itemsName)
        {
            itemsName.Add(n);
        }
 
        lastPosition = new Vector3(t_lastPosition.x, t_lastPosition.y, t_lastPosition.z);
    }
}
cs


위의 코드는 저장할 변수들을 간추린 SaveData 클래스이다.

위의 코드에서 중요한 부분은, class 상단에 [System.Serializable] 어트리뷰트를 붙여줘야 한다는 것이다.

[System.Serializable] 어트리뷰트는 이 클래스를 직렬화 할 수 있게 표식을 남겨주는 의미이다.



직렬화라는 것은 데이터를 byte단위로 한가닥으로 길게 뽑아낸다고 생각하면 되는데, 

인터넷 통신을 할 때나 파일을 읽고 쓸 때는 데이터가 byte단위로 이동하기 때문에 세이브 데이터를 만들려면 이 표식을 붙여줘야 한다.



그 다음에 위의 코드에서 눈여겨볼 것은 저장할 변수 앞에 붙은 [SerializeField] 어트리뷰트이다.

내가 저장하고 싶은 변수를 체크하는 방법은 크게 2가지가 있다.

1. 변수를 public 접근지정자로 선언한다.

2. 변수를 private 접근지정자로 선언한 후 [SerializeField]를 앞에 붙여준다.

나 같은 경우에는 플레이어의 정보를 은닉하고 싶기 때문에 private으로 선언했고, 그 때문에 앞에 [SerializeField]를 붙여주었다.




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System.Security.Cryptography;
 
public class SaveManager : MonoBehaviour
{
    private static readonly string privateKey = "1718hy9dsf0jsdlfjds0pa9ids78ahgf81h32re";
 
    public static void Save()
    {
        //원래라면 플레이어 정보나 인벤토리 등에서 긁어모아야 할 정보들.
        SaveData sd = new SaveData(
            "글릭"
            10
            0.1f, 
            new List<string> { "포션""단검" }, 
            new Vector3(123));
 
        string jsonString = DataToJson(sd);
        string encryptString = Encrypt(jsonString);
        SaveFile(encryptString);
    }
 
    public static SaveData Load()
    {
        //파일이 존재하는지부터 체크.
        if(!File.Exists(GetPath()))
        {
            Debug.Log("세이브 파일이 존재하지 않음.");
            return null;
        }
 
        string encryptData = LoadFile(GetPath());
        string decryptData = Decrypt(encryptData);
 
        Debug.Log(decryptData);
 
        SaveData sd = JsonToData(decryptData);
        return sd;
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    //세이브 데이터를 json string으로 변환
    static string DataToJson(SaveData sd)
    {
        string jsonData = JsonUtility.ToJson(sd);
        return jsonData;
    }
 
    //json string을 SaveData로 변환
    static SaveData JsonToData(string jsonData)
    {
        SaveData sd = JsonUtility.FromJson<SaveData>(jsonData);
        return sd;
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    //json string을 파일로 저장
    static void SaveFile(string jsonData)
    {
        using (FileStream fs = new FileStream(GetPath(), FileMode.Create, FileAccess.Write))
        {
            //파일로 저장할 수 있게 바이트화
            byte[] bytes = System.Text.Encoding.UTF8.GetBytes(jsonData);
 
            //bytes의 내용물을 0 ~ max 길이까지 fs에 복사
            fs.Write(bytes, 0, bytes.Length);
        }
    }
 
    //파일 불러오기
    static string LoadFile(string path)
    {
        using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read))
        {
            //파일을 바이트화 했을 때 담을 변수를 제작
            byte[] bytes = new byte[(int)fs.Length];
 
            //파일스트림으로 부터 바이트 추출
            fs.Read(bytes, 0, (int)fs.Length);
 
            //추출한 바이트를 json string으로 인코딩
            string jsonString = System.Text.Encoding.UTF8.GetString(bytes);
            return jsonString;
        }
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    private static string Encrypt(string data)
    {
 
        byte[] bytes = System.Text.Encoding.UTF8.GetBytes(data);
        RijndaelManaged rm = CreateRijndaelManaged();
        ICryptoTransform ct = rm.CreateEncryptor();
        byte[] results = ct.TransformFinalBlock(bytes, 0, bytes.Length);
        return System.Convert.ToBase64String(results, 0, results.Length);
 
    }
 
    private static string Decrypt(string data)
    {
 
        byte[] bytes = System.Convert.FromBase64String(data);
        RijndaelManaged rm = CreateRijndaelManaged();
        ICryptoTransform ct = rm.CreateDecryptor();
        byte[] resultArray = ct.TransformFinalBlock(bytes, 0, bytes.Length);
        return System.Text.Encoding.UTF8.GetString(resultArray);
    }
 
 
    private static RijndaelManaged CreateRijndaelManaged()
    {
        byte[] keyArray = System.Text.Encoding.UTF8.GetBytes(privateKey);
        RijndaelManaged result = new RijndaelManaged();
 
        byte[] newKeysArray = new byte[16];
        System.Array.Copy(keyArray, 0, newKeysArray, 016);
 
        result.Key = newKeysArray;
        result.Mode = CipherMode.ECB;
        result.Padding = PaddingMode.PKCS7;
        return result;
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    //저장할 주소를 반환
    static string GetPath()
    {
        return Path.Combine(Application.persistentDataPath, "save.abcd");
    }
 
}
cs



참고로 암호화 부분은 https://gist.github.com/TarasOsiris/9020497 이곳을 참고해 만들었다.


외부에서 쓰는 public 함수는 Save와 Load밖에 없다.

그 밑의 함수들은 모두 Save와 Load 안에서 실행될 뿐인 함수들이다.


암호화는 많은 공부가 필요한데 간략히만 설명하도록 하겠다.



일단 RijndaelManaged 클래스가 뭔지부터 알아야 하는데, 암호화와 관련된 것들을 총괄해주는 클래스이다.


코드의 123번째 줄부터 보면 설정을 해주는데 각각의 의미를 아주 간단하게 말하자면 아래와 같다.

Key : 암호화와 해석에 필요한 키. 외부로 유출되면 안된다.

Mode : 암호화 방식. 여기서 ECB는 key를 이용한 가장 간단한 암호화 방식이라고 할 수 있다.

padding : 데이터가 전체 암호화에 필요한 바이트보다 짧을 때 남은 바이트를 채워주는 방식을 설정한다.


이 RijndaelManaged로부터 암호화를 시켜주는 Encryptor와 암호화를 풀어주는 Decryptor를 만들 수 있다.



참고로 Application.persistentDataPath는 윈도우10 기준으로 아래와 같다.

C:\Users\(사용자이름)\AppData\LocalLow\(회사이름)\(유니티 프로젝트 이름)



위의 암호화 방식 없이 파일로 저장하면, 그 파일을 메모장으로 열었을 때 저장한 데이터가 고스란히 나오지만,

암호화를 한 파일을 연다면 이상한 문자들이 나오는 것을 알 수 있을 것이다.



오브젝트 풀링 기법은 유니티 게임 프로그래밍을 배울 때 가장 먼저 배우는 기법들 중 하나일 것이다.


일단 오브젝트 풀링이 왜 필요하고 정체가 무엇인지 예시를 들어 설명하겠다.


Strikers 1945 같은 전투기 슈팅 게임을 만들 때, 유저는 화면에 적이 있든 없든 총알 발사 버튼을 마구잡이로 누르게 된다.

이 때 총알 오브젝트를 Instantiate, Destroy 함수를 이용해서 생성과 소멸을 끊임없이 반복한다면 어떻게 될까?




1. 메모리 단편화(Memory fragmentation) 문제의 발생


바로 메모리 단편화(=메모리 파편화)라는 문제가 생기게 된다. 

메모리 단편화에는 외부 메모리 단편화내부 메모리 단편화가 있는데, 위의 상황에서는 외부 메모리 단편화가 발생한다..


외부 메모리 단편화란, 메모리에 빈 공간이 충분함에도 불구하고 빈 공간들이 따로 떨어져 있어서, 새로운 오브젝트를 생성하지 못하는 현상을 말한다.


아래 그림을 보면 더 쉽게 이해가 된다.


메모리 공간



위 그림의 상황 : 유니티에서 Instantiate 함수를 이용해서 오브젝트1 ~ 오브젝트4 에게 공간을 할당해줬다.



외부 메모리 단편화 상황



게임 진행을 하다보니 오브젝트2가 필요가 없어져서 Destroy함수를 이용해서 없애줬다.

그리고서 새로운 오브젝트5를 Instantiate함수로 생성하려고 했는데, 오브젝트5에게 할당해줄 알맞은 메모리 공간이 없다!

빈 공간 3개를 합치면 오브젝트5에게 할당해줄 연속된 공간이 충분할텐데, 빈 공간이 따로 떨어져있다보니 오브젝트5 생성에 문제가 생긴 것이다.


이렇게 Instantiate와 Destroy를 계속 사용하다보면 빈 공간이 우후죽순 생기게 되고, 메모리가 효율적으로 관리되지 못한다.

이런 상황을 외부 메모리 단편화라고 부른다.




2. 유니티는 메모리 단편화를 해결하지 못한다

보통의 C#에서는 가비지컬렉터(GC)가 메모리 단편화를 Compaction 해서 해결한다.
Compaction은 한글로 번역하면 압밀이라고 하는데, 압축, 밀착이라는 의미이다.

Compaction 진행 후. 이제 오브젝트 5가 들어갈 곳이 생겼다.


위의 그림과 같이, Compaction이 진행되면 오브젝트들의 메모리 공간을 좌로 밀착시키고, 빈 공간을 이어지게 해서 오브젝트5에게 메모리 공간을 할당해줄 수 있게 되었다.

하지만, 유니티의 GC는 Compaction기능을 지원하지 않는다.(출처 : https://docs.unity3d.com/kr/current/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html)


그렇기 때문에 Instantiate와 Destroy를 자주 해서 빈공간이 우후죽순 생기게 되면, 나중에 오브젝트 5처럼 거대한 객체가 만들어졌을 때 들어갈 공간이 없으니 메모리 점유를 늘리게 되고, 결과적으로는 비효율적인 메모리 운용이 생길 수 밖에 없다. 최악의 경우에는 유니티가 종료되거나 컴퓨터가 다운될 수도 있다.


그래서 Instantiate(혹은 new)와 Destroy의 사용을 최대한 자제하는게 유니티 메모리 최적화의 핵심이다.




3. 기초적인 오브젝트 풀링(Object Pooling)

그럼 아까 상황으로 돌아와서, Strikers 1945같은 전투기 슈팅게임을 만든다고 해보자. 버튼을 누를 때마다 총알이 나가는데, 이때마다 Instantiate를 호출해서 총알을 생성하고, 총알이 적에게 닿거나 맵 밖으로 나가면 Destroy로 총알을 삭제한다면? 무수한 빈 공간이 생길 것이다.

물론 아까 생성한 총알과 똑같은 용량의 총알이 또 생성된다면, 예전에 생성되었던 총알이 Destroy된 후 생겨난 빈 공간을 새로운 총알에게 할당해줄 수도 있을 것이다. 하지만 파워업 아이템을 먹어서 총알에 이펙트나 효과음이 추가되어서 용량이 커지게 된다면? 위의 그림들에서 오브젝트2가 삭제된 후 남겨진 빈 공간에 오브젝트5가 못 들어가는 것과 같은 상황이 발생할 것이다.

그래서 적당히 수십발의 총알을 미리 생성해놓고, 적에게 총알이 닿거나 맵 밖으로 총알이 나가면 SetActive(false)를 해서 안보이게 한 후, 나중에 총알을 재사용하는 방법을 오브젝트 풀링이라고 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
//전투기 클래스
public class Flight : MonoBehaviour
{
    //총알 프리팹
    public Bullet prefab_bullet;
 
    //총알 Pool
    private List<Bullet> bulletPool = new List<Bullet>();
 
    //내가 생성할 총알 갯수
    private readonly int bulletMaxCount = 10;
 
    //현재 장전된 총알의 인덱스
    private int curBulletIndex = 0;
 
    void Start()
    {
        //총알 10개 미리 생성
        for(int i = 0; i < bulletMaxCount; ++i)
        {
            Bullet b = Instantiate<Bullet>(prefab_bullet);
            
            //총알 발사하기 전까지는 비활성화 해준다.
            b.gameObject.SetActive(false);
 
            bulletPool.Add(b);
        }
    }
 
    void Update()
    {
        FireBulet();
    }
 
    //총알 발사
    void FireBulet()
    {
        //마우스 좌클릭 할 때마다 총알 발사
        if (Input.GetMouseButtonDown(0))
        {
            //발사되어야할 순번의 총알이 이전에 발사한 후로 아직 날아가고 있는 중이라면, 발사를 못하게 한다.
            if(bulletPool[curBulletIndex].gameObject.activeSelf)
            {
                return;
            }
 
            //총알 초기 위치는 전투기랑 같게
            bulletPool[curBulletIndex].transform.position = this.transform.position;
 
            //총알 활성화 해주기
            bulletPool[curBulletIndex].gameObject.SetActive(true);
 
            //방금 9번째 총알을 발사했다면 다시 0번째 총알을 발사할 준비를 한다.
            if (curBulletIndex >= bulletMaxCount - 1)
            {
                curBulletIndex = 0;
            }
            else
            {
                curBulletIndex++;
            }
        }
    }
}
cs


총알의 이동함수, 적에게 닿거나 맵 밖으로 나갔을 때 SetActive(false) 해주는 부분은 굳이 구현하지는 않겠다.

총알을 여러개 만들어서 리스트에 담아주고, 그것들을 필요할 때 빼서 쓰는 것이 기본 골자이다.


안쓰는 총알용 List와 Active된 총알용 List를 따로 만들어줘서, 총알을 발사하면 이 리스트에서 저 리스트에 넣어주고 이런식으로 관리를 해주는 방법도 있다.

또 위의 예제에서는 bulletPool[curBulletIndex] 이런식으로 List를 직접 가져다 썼지만, 총알 종류가 여러개라서 바꿔 쏠 수 있는 게임이라면, List를 여러개 만들어준 후 Get 함수를 만들어서 현재 유저가 설정한 총기 종류에 따라서 다른 List의 총알을 리턴해주는 방식도 있을 것이다.





오브젝트 풀링은 위에서 말했던 것처럼 메모리 단편화 문제 때문에도 쓰이고, 

애당초 Instantiate와 Destroy 함수 자체가 비용이 큰 함수이기 때문에 런타임때 자주 써주는건 좋지 못하다.


오브젝트 풀링의 안 좋은 점이라면, 내가 총알을 발사할 필요가 없을 때에도 총알이 메모리에 존재한다는 것인데, 총알의 max 갯수는 테스트를 통해 적당한 수치를 찾는게 좋을 것이다.

상태와 전략은 둘 다 기본 골자는 비슷하다.


상태와 전략의 기본 골자






상태와 전략 둘 다 Context에서 InterfaceParent.Execute()를 실행한다는 것이 포인트이다. 객체지향적인 설계이기 때문인데, ConcreteSon의 종류가 현재는 2개 뿐이지만 3, 4, 5.....로 늘어나더라도 Context는 InterfaceParent.Execute()를 실행할 뿐이므로, OCP(Open-Closed Principle : 확장에는 열리고, 수정에는 닫혀야 한다는 객체지향의 원리)를 지킬 수 있다. 만약 Context에서 ConcreteSon1.Execute(), ConcreteSon2.Execute()...... 이런식으로 실제 객체의 함수를 직접호출 했다면, ConcreteSon이 늘어날 때마다 Context 내부를 수정해야 하기 때문에 지저분한 코드가 될 것이다.



기본 골자가 비슷하다면 전략패턴과 상태패턴의 차이는 무엇인가?


바로 SetConcreteSon을 어디서 호출할 것인가 이다.


이 블로그의 전략패턴 글에서, SetState함수(SetConcreteSon함수에 해당)를 키보드 입력에 따라서 Player(Client에 해당)에서 호출하고 있다.

이 블로그의 상태패턴 글에서, SetState함수(SetConcreteSon함수에 해당)를 StateOpen과 StateClose(ConcreteSon1, 2에 해당)에서 호출하고 있다.


즉, 외부에서 인위적으로 ConcreteSon 객체를 바꿔준 후 Execute()함수를 호출하고 싶다면 전략패턴을 사용해야한다.

반면, Client는 단순히 Context.Operate()함수를 호출해줄 뿐이고, 그때마다 내부적으로 알아서 ConcreteSon 객체가 바뀌게 하고 싶다면 상태패턴을 사용하면 된다.


구조의 골자는 비슷하지만 목적이 다른 두 패턴이므로 적재적소에 써주면 된다.

게임에서 어떤 레버를 당기면 문이 열리고, 그 레버를 또 당기면 문이 닫힌다고 해보자. 내가 어떤 동작을 수행했을 때 내부적으로 현재 상태를 알아서 바꾸는 경우, 상태패턴을 사용하면 객체지향적으로 간단히 만들 수 있다.



플레이어가 레버를 클릭한다 -> 닫힌 상태였다면 열림 / 열린 상태였다면 닫힘.



클래스 구조를 그림으로 보면 아래와 같이 된다.


클래스 구조




함수의 호출 순서를 글로 풀어보면 아래와 같다.


1. 플레이어가 레버 앞에서 액션키를 누름 -> Lever의 Handle() 함수 실행


2. Handle()함수 안에는 context.Operate()함수가 실행됨.


3. Operate()함수 안에는 curState.Execute(DoorStateContext context, Door door)함수가 실행됨.


4. Execute(...) 함수 안에는 매개변수 door의 Open() 혹은 Close() 함수가 실행되고, 그 후에 매개변수 context의 SetState(...) 함수를 이용해서 현 상태를 바꿔줌.



플레이어는 레버를 누를 뿐이고, 그 레버는 context의 Operate()함수를 실행할 뿐이다. 그럼 하위 State 객체들이 알아서 상태를 바꿔준다.





본격적인 코드는 아래와 같다.



1
2
3
4
5
6
7
8
9
10
public class Lever : MonoBehaviour
{
    public DoorStateContext context;
 
    //플레이어가 레버를 당겼을 때 실행되는 함수
    public void Handle()
    {
        context.Operate();
    }
}
cs




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class DoorStateContext : MonoBehaviour
{
    //레버에 반응할 문 게임오브젝트.
    //유니티 인스펙터에서 드래그해서 넣어주기로 하자.
    public Door door;
 
    //열리거나 닫히거나, 현재 상태.
    public IState curState;
 
    void Start()
    {
        //기본은 닫힌 상태.
        curState = new StateClose();
    }
 
    //현재 상태의 동작을 호출해주는 함수.
    public void Operate()
    {
        curState.Execute(this, door);
    }
 
    //새로운 상태를 세팅해줄때 사용하는 함수.
    public void SetState(IState newState)
    {
        curState = newState;
    }
}
cs



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public interface IState
{
    //상태의 행동 선언.
    void Execute(DoorStateContext context, Door door);
}
 
 
public class StateOpen : IState
{
    //상태의 행동 정의.
    public void Execute(DoorStateContext context, Door door)
    {
        //열려있다면 닫아준다.
        door.Close();
 
        //닫힘으로 상태 전환.
        context.SetState(new StateClose());
    }
}
 
 
public class StateClose : IState
{
    //상태의 행동 정의.
    public void Execute(DoorStateContext context, Door door)
    {
        //문이 닫혀있을 땐 열어준다.
        door.Open();
 
        //상태를 열림상태로 전환.
        context.SetState(new StateOpen());
    }
}
cs




1
2
3
4
5
6
7
8
9
10
11
12
public class Door : MonoBehaviour
{
    public void Open()
    {
        Debug.Log("열림");
    }
 
    public void Close()
    {
        Debug.Log("닫힘");
    }
}
cs


횡스크롤 러닝 게임이 있다고 해보자. 사용자가 입력할 수 있는 버튼은 점프와 슬라이딩이 있다. 그럼 캐릭터가 가질 수 있는 상태는 아래와 같을 것이다.


달리기(평소)

점프

슬라이딩

죽음


만약 게임이 업데이트가 되어서 상태들이 늘어날 수도 있을 것이다. 기획자에 따라서 점프 도중 한 번 더 점프를 할 수 있는 이중점프, 점프시 빠르게 땅으로 내려오는 급하강, 무적아이템을 먹었을 때의 무적상태 등등을 추가할 수도 있다. 그 때마다 다른 클래스에서 이 상태들을 사용할 때 이중점프.Do() 혹은 급하강.Do() 이런 식으로 직접 써준다면, 상태가 늘어날 때마다 그 상태들을 사용하는 클래스를 수정해야 할 것이다. 그것은 객체지향의 Open-Closed Principle(확장에는 열리게 하고, 수정에는 닫히게 해야 한다는 객체지향 원칙)을 어기는 것이다. 그래서 각 상태들을 IState라는 인터페이스로 묶어주고, IState.Do()로 실행하게 한다면, 상태가 아무리 추가가 되어도 IState 변수에 들어갈 상태 객체만 바꿔주면 되기 때문에 더이상 상태를 사용해주는 클래스를 수정하지 않아도 될 것이다.


이런식으로 객체들을 인터페이스로 묶어주고, 객체들의 멤버함수를 실행할 때 직접실행하는게 아니라 인터페이스.Do() 이런식으로 실행한다. 이렇게 OCP를 지치고 좀 더 유연하게 구조를 짜는 것이 전략패턴의 핵심이다. 그리고 Set함수를 만들어서 인터페이스 변수에 내가 원하는 객체를 넣어줄 수 있게 한다.


보통 게임프로그래밍을 본격적으로 시작할 때 가장 먼저 배우는 것 중 하나가 유한상태기계(FSM, Finite State Machine)일 것이다. 키보드 마우스의 입력에 따라서 캐릭터의 상태가 바뀌게 하고, 현재 상태에 따라 다른 행동이나 반응을 하게 하는 것, 그것이 캐릭터의 유한상태기계이다. 전략 패턴을 사용하면 유한상태기계를 객체지향적으로 만들 수 있다.


위의 횡스크롤 러닝 게임의 유한상태기계를 전략 패턴을 이용해서 간단하게 만들면 아래와 같다.






클래스 구조 설명 : 

Player는 유저가 조작하는 캐릭터이고,

StateMachine은 키보드 입력에 따라 상태를 바꿔주고 각 상태의 Operate 함수를 실행시켜주는, 상태들을 총괄해주는 역할이다.

IState 및 세부 상태들은 맨 위에서 말했듯이 각 상태들을 객체로 만들어준 것이다.



게임 플레이 시에 상태끼리 연결되는 과정 : 

Run 상태일 때 : z를 누르면 점프. X를 누르면 슬라이딩. 적에게 닿으면 죽음.

Jump 상태일 때 : 적에게 닿으면 죽음

Sliding 상태일 때 : z를 누르면 점프, 적에게 닿으면 죽음.

dead 상태일 때 : 키 입력 받지 않음. 마우스로 다시하기 버튼을 클릭하면 게임 리셋(여기서는 구현하지 않겠음).



OperateEnter, OperateUpdate, OperateExit는 무엇 ? :

간략하게 말하자면, Enter 함수에는 상태가 바뀌자마자 하고 싶은 행동을 넣고, Update는 매 프레임마다 하고 싶은 행동을, Exit에는 다른 상태로 바뀌기 직전에 하고 싶은 행동을 넣으면 된다. 보통 객체의 라이프 사이클을 짜다보면 Enter, Update, Exit 구조로 짜는 경우가 많다. 유니티에도 이러한 경우가 많은데, 충돌처리의 OnCollision 함수 시리즈에도 OnCollisionEnter, OnCollisionStay, OnCollisionExit 로 이루어져있고, 유니티의 MonoBehaviour에도 Start, Update OnDestroy 등등의 함수가 존재하는 걸 알 수 있다.



왜 Enter, Update, Exit가 필요? : 

예를 들어 Dead의 경우에는 상태가 바뀌자마자 캐릭터의 모습을 Dead.Png로 바꾸고, 다른 키 입력이 안되게만 하면 되므로 Enter만 필요하게 된다.

반면 Sliding에 경우에는 상태가 바뀌자마자 엎드려있는 모습으로 바꾸고, x버튼에서 손을 떼면 슬라이딩을 끝내고 다시 일어서는 모습으로 바꿔야하므로 Enter와 Exit가 둘 다 필요하다.

이렇게 상태별로 Enter, Exit, Update가 필요한 경우가 있으므로 원활한 게임 제작을 위해서 이런식으로 구조를 짜는 것이다. 




상세한 코드는 아래와 같다.



//플레이어블 캐릭터
public class Player : MonoBehaviour
{
    private enum PlayerState
    {
        Run,
        Sliding,
        Jump,
        Dead
    }

    private StateMachine stateMachine;

    //스테이트들을 보관
    private Dictionary<PlayerState, Istate> dicState = new Dictionary<PlayerState, Istate>();

    // Start is called before the first frame update
    void Start()
    {
        //상태 생성
        IState run = new StateRun();
        IState sliding = new StateSliding();
        IState jump = new StateJump();
        IState dead = new StateDead();

        //키입력 등에 따라서 언제나 상태를 꺼내 쓸 수 있게 딕셔너리에 보관
        dicState.Add(PlayerState.Run, run);
        dicState.Add(PlayerState.Sliding, sliding);
        dicState.Add(PlayerState.Jump, jump);
        dicState.Add(PlayerState.Dead, dead);

        //기본상태는 달리기로 설정.
        stateMachine = new StateMachine(run);
    }

    // Update is called once per frame
    void Update()
    {
        //키입력 받기
        KeyboardInput();

        //매프레임 실행해야하는 동작 호출.
        stateMachine.DoOperateUpdate();
    }

    //키보드 입력
    void KeyboardInput()
    {
        if(Input.GetKeyDown(KeyCode.Z))
        {
            //달리기, 슬라이딩 중일 때만 점프 가능
            if (stateMachine.CurrentState == dicState[PlayerState.Run] || stateMachine.CurrentState == dicState[PlayerState.Sliding])
            {
                stateMachine.SetState(dicState[PlayerState.Jump]);
            }
        }

        if(Input.GetKeyDown(KeyCode.X))
        {
            //달리기 중에만 슬라이딩 가능.
            if(stateMachine.CurrentState == dicState[PlayerState.Run])
            {
                stateMachine.SetState(dicState[PlayerState.Sliding]);
            }
        }
    }

    public void OnTriggerEnter(Collider other)
    {
        if(other.tag == "Enemy")
        {
            stateMachine.SetState(dicState[PlayerState.Dead]);
        }
    }
}



public class StateMachine
{
    //현재 상태를 담는 프로퍼티.
    public IState CurrentState { get; private set; }

    //기본 상태를 생성시에 설정하게 생성자 만들기.
    public StateMachine(IState defaultState)
    {
        CurrentState = defaultState;
    }

    //외부에서 현재상태를 바꿔주는 부분.
    public void SetState(IState state)
    {
        //같은 행동을 연이어서 세팅하지 못하도록 예외처리.
        //예를 들어, 지금 점프중인데 또 점프를 하는 무한점프 버그를 예방할수도 있다.
        if(CurrentState == state)
        {
            Debug.Log("현재 이미 해당 상태입니다.");
            return;
        }

        //상태가 바뀌기 전에, 이전 상태의 Exit를 호출한다.
        CurrentState.OperateExit();

        //상태 교체.
        CurrentState = state;
        
        //새 상태의 Enter를 호출한다.
        CurrentState.OperateEnter();
    }
    
    //매프레임마다 호출되는 함수.
    public void DoOperateUpdate()
    {
        CurrentState.OperateUpdate();
    }
}



//상태들의 최상위 인터페이스.
public interface IState
{
    void OperateEnter();
    void OperateUpdate();
    void OperateExit();
}



//각 세부 상태들은 굳이 정의하지 않겠다.
public class StateSliding : IState
{
    public void OperateEnter()
    {
        
    }

    public void OperateExit()
    {
        
    }

    public void OperateUpdate()
    {
        
    }
}


빌더라는 디자인패턴이 있다. 그런데 Effective Java의 빌더와 GoF 말하는 빌더패턴의 의미가 다르다. 이 글에서는 두 패턴의 차이를 아주 간단한 예제를 통해 알아보도록 하겠다. 설명을 위해 유니티의 특성을 덜 사용하게 되는데 그래도 유니티상에서 코딩을 한 것이기 때문에 제목에 유니티를 넣어주었다.





1. Java에서의 빌더 패턴


일단 Java에서의 빌더 패턴은 쉽게 말하자면 객체를 만들 때 private 변수를 각각 개별 함수로 세팅하고, 변수설정이 끝났다면 객체를 만들어 반환하는 방법이다. 이때 만들어진 객체는 멤버변수를 다시는 수정할 수 없도록 해서 안전하게 보호할 수 있다.


만약 세이브 데이터를 빌더 패턴으로 만든다면 다음과 같은 코드로 만들게 된다.



public class SaveData { public string name{ get; private set; } public int level{ get; private set; } public string location{ get; private set; } public SaveData(string tName, int tLevel, string tLocation) { this.name = tName; this.level = tLevel; this.location = tLocation; } //세이브 데이터를 생성해주는 빌더 클래스. public class SaveDataBuilder { private string name = "defaultName"; private int level = -1; private string location = "시작의 마을 여관"; //개발자가 판단하기에 가장 중요한 변수는 생성자에 넣어서 반드시 세팅되도록 해준다. public SaveDataBuilder(string tName) { this.name = tName; } //이하는 Setter함수들. 리턴값으로 this를 해주는게 바로 포인트. public SaveDataBuilder SetLevel(int tLevel) { this.level = tLevel; return this; } public SaveDataBuilder SetLocation(string tLocationh) { this.location = tLocationh; return this; } //원하는 값들이 세팅되면 최종적으로 이 함수를 실행해서 세이브 데이터 객체를 하나 만들어준다. public SaveData Build() { return new SaveData(name, level, location); } } }



public class AppMain : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        //각 Set 함수를 실행하면 SaveDataBuilder가 리턴되기 때문에 걔속해서 .Set 이런식으로 멤버변수 세팅 가능. 
        //최종적으로 Build()를 실행하면 설정한 값에 따라 SaveData객체가 만들어짐.
        //이후엔 save1의 멤버변수를 수정할 방도가 없어서, 데이터를 보호할 수 있음.
        //만약 내가 설정하기 싫은 변수가 있으면 그 변수에 대한 Set함수를 사용 안하면 된다.
        SaveData save1 = new SaveData.SaveDataBuilder("글릭").SetLevel(1).SetLocation("발라마을 여관").Build();

    }
}





2. GoF의 빌더 패턴



GoF의 빌더 패턴은 새로운 객체를 만드는 방법론이라는 점은 Java의 빌더 패턴과 같지만, 전혀 관점이 다르다. 


Java의 빌더패턴은 Set함수를 여러개 써서 내가 원하는 변수를 세팅하고, Build() 후에는 멤버변수를 수정할 수 없게 해서 안전하게 보호하는 데에 목적이 있다.


반면 GoF의 빌더 패턴은 객체지향적인 방식인데, 예시를 통해 설명하겠다.



생과일 주스를 만드는 타이쿤 게임이 있다고 해보자. 손님이 원하는대로 포도주스, 딸기주스를 만들어서 줘야한다. 기계에 과일을 넣고, 버튼을 눌러 즙을 짜서 손님에게 주는 것은 동일하지만, 재료가 포도이냐 딸기냐가 다르다.


그럼 빌더패턴에 따라서 과일만들기 기능을 설계해보겠다.


 클래스 명

 설명

빌더 패턴 내에서의 명칭

 Juice

 손님에게 줄 주스. 멤버변수 type에 따라 포도주스 혹은 딸기주스가 된다.

product 

 JuiceBuilder

 주스 객체를 만들고 세팅하는데에 필요한 멤버함수나 멤버변수를 선언해주는 추상 클래스.

 Builder

 StrawberryJuiceBuilder

GrapeJuiceBuilder

 JuiceBuilder를 상속받아 딸기주스 혹은 포도주스를 만드는 기능을 정의할 수 있는 실질적인 객체들.

Concrete Builder 

 JuiceMachine

 JuiceBuilder의 함수들을 사용해서 때로는 딸기주스빌더, 때로는 포도주스빌더를 이용해 딸기주스와 포도주스를 만들어내는 과일가계의 주스기계.

 Director


위의 표를 토대로 만든 코드는 아래와 같다.



//주스.
public class Juice
{
    public enum JuiceType
    {
        None,
        Grape,
        Strawberry
    }

    public JuiceType type = JuiceType.None;

    public void SetType(JuiceType type)
    {
        this.type = type;
    }
}



//주스 빌더 추상클래스.
public abstract class JuiceBuilder
{
    //만들어질 주스를 담을 변수
    protected Juice juice;

    //주스를 리턴
    public Juice CreateJuice()
    {
        return juice;
    }

    //새로운 주스를 만들 준비
    public void PrepareNewJuice()
    {
        juice = new Juice();
    }

    //주스를 만들기 전에 과일 세팅을 하는 추상함수. 내용물 정의는 하위 클래스에서 해줄 것이다.
    public abstract void SetFruit();
}



//딸기주스 빌더
public class StrawberryJuiceBuilder : JuiceBuilder
{
    public override void SetFruit()
    {
        juice.SetType(Juice.JuiceType.Strawberry);
    }
}

//포도주스 빌더
public class GrapeJuiceBuilder : JuiceBuilder
{
    public override void SetFruit()
    {
        juice.SetType(Juice.JuiceType.Grape);
    }
}



//주스 빌더를 사용할 주스 머신
public class JuiceMachine
{
    //딸기주스빌더 혹은 포도주스빌더를 담을 변수.
    private JuiceBuilder juiceBuilder;

    //딸기주스빌더 혹은 포도주스빌더를 세팅하는 함수.
    public void SetJuiceBuilder(JuiceBuilder builder)
    {
        juiceBuilder = builder;
    }

    //빌더가 세팅되었다면 본격적으로 주스를 만들 준비를 한다.
    public void MakeJuice()
    {
        juiceBuilder.PrepareNewJuice();
        juiceBuilder.SetFruit();
    }

    //주스 만들 준비가 끝났다면 주스를 만들어서 손님에게 준다.
    public Juice GetJuice()
    {
        return juiceBuilder.CreateJuice();
    }
}



public class AppMain : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        JuiceMachine machine = new JuiceMachine();
        GrapeJuiceBuilder grapeJuiceBuilder = new GrapeJuiceBuilder();
        StrawberryJuiceBuilder strawberryJuiceBuilder = new StrawberryJuiceBuilder();

        //포도주스 제조.
        machine.SetJuiceBuilder(grapeJuiceBuilder);
        machine.MakeJuice();
        Juice grapeJuice = machine.GetJuice();

        //딸기주스 제조.
        machine.SetJuiceBuilder(strawberryJuiceBuilder);
        machine.MakeJuice();
        Juice strawberryJuice = machine.GetJuice();

    }
}


컴포지트 패턴은 예시를 통해 설명하겠다.




위의 그림과 같은 인벤토리 창이 있는 RPG게임이 있다고 해보자. 아이템 슬롯에는 포션이나 무기 같은 아이템을 보관할 수 있는데, 아이템 중에 4차원 가방이라는 아이템이 있다. 그 가방은 자신도 아이템이라서 아이템 슬롯에 위치하는데, 그 가방을 더블클릭하면 또다른 아이템 슬롯 창이 뜨고, 거기에도 아이템을 보관할 수 있다고 해보자. 이런 구조를 컴포지트 패턴을 이용해 설계한다면 아래와 같이 만들 수 있다.






위의 클래스 구조를 간략히 설명하면 다음과 같다.


클래스 명

 설명

 컴포지트 패턴 내에서의 용어

 Item

 모든 아이템은 이 추상클래스를 상속받아 만들어진다.

 Component

 ItemLeaf

 4차원 가방을 제외한 다른 아이템들은 ItemLeaf를 상속받아 만들어진다. Leaf는 잎, 말단이란 뜻으로, 상위클래스인 Item과 구별해주기 위해 접미사로 붙여주었다.

 Leaf(단일객체)

 ItemBag

 4차원 가방 아이템. 이 아이템은 자신도 Item이면서 다른 아이템을 List에 넣어 관리할 수 있다. 자기 자신이 추가 인벤토리 아이템 슬롯인 셈.

 Composite(복합객체)



type은 물약같은 사용 아이템인지, 무기같은 장착 아이템인지 구별해주는 멤버변수인데, 뭔가 허전해서 넣어봤을 뿐이며 자신의 게임에 맞게 수정하거나 빼버리면 될 것이다.




자세한 코드는 아래와 같다.


//최상위 아이템 추상클래스
public abstract class Item
{
    public enum Type
    {
        Use,
        Equip,
        Own,
        Main
    }

    public string name;
    public Type type;

    //아이템이 수행할 동작을 여기에 정의하거나 실행.
    public abstract void Operate();

    //생성자
    public Item(string name, Type type)
    {
        this.name = name;
        this.type = type;
        Debug.Log("부모클래스 세팅 : " + name + " " + type.ToString());
    }
}


//4차원 가방 클래스
public class ItemBag : Item
{
    private List itemList = new List();

    public ItemBag(string name, Type type = Type.Use) : base(name, type)
    {
        
    }

    public override void Operate()
    {
        OpenSlotWindow();
    }

    //가방의 인벤토리 창을 여는 멤버함수. 본 예제에서는 내부를 정의하진 않겠다.
    private void OpenSlotWindow()
    {

    }

    //가방에 아이템 넣을때 사용하는 함수
    public void Add(Item item)
    {
        if(itemList.Contains(item))
        {
            Debug.Log(" 버그 : 이미 해당 아이템이 리스트에 존재함.");
            return;
        }
        itemList.Add(item);
    }

    //가방에서 아이템 뺄때 사용하는 함수
    public void Remove(Item item)
    {
        if(!itemList.Contains(item))
        {
            Debug.Log("버그 : 해당 아이템이 리스트에 존재하지 않음.");
            return;
        }
        itemList.Remove(item);
    }
}


//일반 아이템 클래스
public class ItemLeaf : Item
{
    //생성자
    public ItemLeaf(string name, Type type) : base(name, type)
    {
        
    }

    public override void Operate()
    {
        
    }
}


//추가 예시. 물약을 만들어보았다.
public class Potion : ItemLeaf
{
    public Potion(string name, Type type) : base(name, type)
    {
        
    }

    //또 오버라이딩 해준다. 그럼 ItemLeaf의 Operate함수는 실행되지 않을 것이다.
    public override void Operate()
    {
        UsePotion();
    }

    //체력을 채우고, 물약 갯수를 1 감소시켜주는 기능을 함수내부에 만들어주면 될 것이다.
    private void UsePotion()
    {

    }
}

주의사항
: 본 예제는 컴포지트 패턴의 설명을 돕기 위해 만든 설명용 예제일 뿐이다. 실제 게임에 적용하려면 많은 고민과 수정을 거쳐야 할 것이며, 아이템 구조를 짤 때 컴포지트 패턴을 사용하지 않을 수도 있다.


+ Recent posts