타입 객체 패턴 (게임 프로그래밍 패턴 13장)

PenguinGod·2022년 10월 9일
2
post-thumbnail

몬스터

게임에서 몬스터는 빠질 수 없는 존재라 할 수 있죠. 가상 생명체가 고통에 차 지르는 비명보다 플레이어의 피를 들끓게 하는 것은 없습니다.

예시를 하나 들자면 우리나라의 대표적인 RPG게임 중 하나인 메이플스토리는 수천 마리 이상의 필드 몬스터를 잡을 수 있으며 수백 개 이상의 종족이 있습니다. 이런 기획을 저희가 구현해야 한다고 생각하고 코드를 만들어봅시다.

많은 몬스터가 있다보니 유연성을 위해 객체지향적으로 만들어봅시다.
역시 객체지향하면 상속이죠. 아마 Monster라는 상위 객체가 있고 공통적인 특성을 공유하는 종족 스크립트를 만들어야겠군요.

자 그럼 만들어야 하는 종족들을 한 번 나열해볼까요?

버섯, 슬라임, 골렘, 와이번, 드레이크, 발록, 조개, 거미, 요정, 호박, 양파, 두더지, 거미, 정령, 안드로이드, 스텀프, 책장, 메모지, 귀신, 스티지, 화장품, 수호자, 신관, 식물, 켄타우로스, 고래, 페페, 가고일........ 네? 아직도 오백개 넘게 남았다구요?

딱 봐도 몬스터들을 하나하나 스크립트로 만드는 건 미친 짓인거 같습니다. 그리고 메이플은 지금도 꾸준히 신규 지역이 나오고 있고 그때마다 몬스터가 대충 20~30마리 정도는 새로 나오는 거 같은데 그때마다 계속 스크립트를 만드는 것도 할 짓이 아닌 것 같습니다.

바로 이럴 때 쓰라고 있는 게 <게임 프로그래밍 패턴> 13장에서 소개하는 타입 객체 패턴입니다.

타입 객체 패턴이란?

우선 저는 저자인 로버트 나이스트롬이 패턴의 이름을 너무 어렵게 지은 것 같다고 생각합니다. 왜 그런지는 우선 패턴에 대해 설명을 드리고 말씀드리겠습니다.

타입 객체 패턴은 앞서 보셨듯이 종족마다 스크립트를 만드는 게 비효율적이라고 판단될 때, 즉 수많은 종류의 객체가 필요하고 객체가 자주 변경, 삭제, 추가되므로 이러한 작업을 유연하게 할 수 있는 구조가 필요할 때 사용하는 패턴입니다.

말은 거창하지만 생각보다 간단합니다. 코드가 아니라 데이터를 통해 몬스터를 만드는 겁니다.

말로 듣자면 뚱딴지같은데 이 이미지로 설명할 수 있습니다.

namelevelhpdamage
주황버섯1012541
슬라임78025

저희는 수백개의 종족을 직접 코딩하는게 아니라 기획자가 데이터를 입력해 직접 종족을 만들 수 있는 시스템을 제공합니다. 위에 테이블같은 경우는 한 줄마다 종족 하나인 거죠.

실제 필드에서 유저와 장렬하게 싸울 몬스터들은 미리 만들어둔 종족값을 참조합니다. 여기서 각 종족은 개념적으로 서로 다른 타입을 의미합니다.

코드 상에서 주황버섯과 슬라임은 서로 다른 종족값을 참조하긴 하겠지만 어쨌든 서로 같은 타입의 클래스를 참조합니다. 하지만 개념적으로 다른 타입으로 구분하는 것이죠.

이래서 타입 객체 패턴이라는 것인데. 저는 개인적으로 이 이름 때문에 좀 햇갈렸습니다. 타입과 객체의 관계를 깨닫는데 시간이 좀 걸려서 그랬는데,
개인적으로 "데이터 객체 패턴" 같은 이름이 좀 더 어울려 보입니다.

잘 이해가 안되신다면 우선은 종족 프리팹을 만든다고 생각하시면 편합니다. 유니티에서 프리팹을 만들면 그것을 이용해 오브젝트를 생성하는 것처럼 종족 프리팹을 만들어서 몬스터를 생성하는 것이죠.

간단한 예제 코드

간단한 Unity 예제 코드로 어떻게 사용하는지 봐봅시다.

public class MonsterType
{
    string _name;
    public string Name => _name;

    int _level;
    public int Level => _level;

    int _hp;
    public int Hp => _hp;

    int _damage;
    public int Damage => _damage;
}

현재 MonsterType 클래스는 4개의 필드만 가지고 있습니다.
아래에 있는 Monster 클래스는 public 프로퍼티들을 이용해 자신을 정의할 겁니다.

public class Monster : MonoBehaviour
{
    [SerializeField] MonsterType _type;
    public MonsterType Type => _type;
    [SerializeField] int _currentHp;
    
    public void SetInfo(MonsterType type)
    {
        _type = type;
        gameObject.name = _type.Name;
        _currentHp = _type.Hp;
    }
    
    public event Actoin<Monster> OnDead = null;
    public void OnDamaged(int damage)
    {
        _currentHp -= damage;
        if(_currentHp <= 0)
        {
        	_currentHp = 0;
        	OnDead?.Invoke(this);
        }
    }
    
    // 공격, 이동 등 다른 로직들.....
}

Monster 인스턴스는 Setinfo()함수를 통해 MonsterType을 받아서 데이터를 덮어쓰고 오브젝트 이름을 바꾸는 등의 작업을 합니다.

그렇다면 어딘가에서는 몬스터를 스폰할 때 MonsterType을 전달해주어야 합니다. 여기서는 스포너에서 해주겠습니다.

using System.Collections.Generic;
using UnityEngine;
using System.Linq;

public class MonsterSpawner : MonoBehaviour
{
	[SerializeField] GameObject _monsterPrefab;
    static GameObject monsterPrefab; // 전역 함수에서 쓰기 위한 변수
    [SerializeField] TextAsset monsterTypes; // csv 파일
    static Dictionary<string, MonsterType> _nameByMonsterType = new Dictionary<string, MonsterType>();

    void Awake()
    {
    	// 데이터 로드
        _nameByMonsterType =
        	CsvUtility.CsvToArray<MonsterType>(monsterTypes.text).ToDictionary(x => x.Name, x => x);
    }
    
    public static Monster SpawnMonster(string monsterName, Vector2 pos)
    {
        var monster = Instantiate(monsterPrefab, pos, Quaternion.identity).AddComponent<Monster>();
        monster.SetInfo(_nameByMonsterType[monsterName]);
        return monster;
    }
}

전역 함수를 제공해 어디서든 몬스터를 소환할 수 있도록 했습니다.

실제로 메이플같은 시스템을 구현한다면 맵에 들어갈때마다 그 필드 전용 키를 이용해 몬스터를 스폰하면 될 것입니다.

참고로 데이터를 로드하는 코드에서 보이는 CsvUtility는 JsonUtility와 비슷한 기능을 Csv파일에 지원하는 라이브러리입니다. 이 링크로 가시면 관련 정보를 확인하실 수 있습니다.
참고로 개발자는 접니다. 많이 사용해주세요~

실제로 소환해보자

코드를 보긴 했지만 그래도 한 번 돌아가는 걸 보고 싶으니 몇 가지 작업을 추가로 해서 간단한 소환을 해보겠습니다.

우선 소환을 하려면 이미지가 필요한데 저는 그게 없으니 간단한 색깔놀이를 하겠습니다. 참고로 위에 내용과 코드가 중복되는 부분은 주석으로 넘겼습니다.

색깔놀이를 위해 Color필드를 추가합니다.

public class MonsterType
{
    // 윗부분은 동일
    
    [SerializeField] Color _color;
    public Color => _color;
}
public class Monster : MonoBehaviour
{
	// 필드는 동일
    
    public void SetInfo(MonsterType type)
    {
        // 윗부분은 동일
        GetComponent<SpriteRenderer>().color = _type.Color;
    }
}

추가된 부분은 몬스터가 스폰이 된 순간 자신의 색깔을 바꾸는 부분입니다.

이제 간단하게 스포너에서 모든 몬스터를 스폰해보겠습니다.

그 전에 여러 준비 작업을 해줘야 하는데 우선 당연하게도 데이터가 필요하겠죠? 저는 Csv파일을 이용해 패턴을 구현하도록 하겠습니다. 당연하지만 Json, xml등 다른 포멧으로도 구현이 가능합니다. (실제로 책에서는 Json으로 예시를 들었습니다.)

보시는 바와 같이 총 5마리를 소환해보도록 하겠습니다.

그리고 이 다섯 마리의 근간이 될 프리팹을 만들어보도록 하죠.

예. 위대한 프리펩. 바로 원입니다. 저게 끝입니다. 스포너 코드로 넘어가죠.
대충 카메라에 보일범직한 곳 내에서 몬스터들을 소환하겠습니다.

using System.Collections.Generic;
using UnityEngine;
using System.Linq;

public class MonsterSpawner : MonoBehaviour
{
	// 필드는 동일

    void Awake()
    {
        // 윗부분은 동일
        SpanwAllMonster();
    }

    void SpanwAllMonster()
    {
        foreach (var monsterType in _nameByMonsterType.Values)
        {
            Vector2 spawnPos 
            	= new Vector2(Random.Range(-9, 9), Random.Range(-4, 4));
            SpawnMonster(monsterType.Name, spawnPos);
        }
    }
    
	// 나머지 부분은 동일
}

게임을 시작하기 전에 잊지 말고 필드들을 연결해줍시다.

그럼 게임을 시작하고 결과를 확인해보죠.

각양각색의 원들이 잘 나왔군요.

컴포넌트를 이용해 동작도 데이터로 구현하기

지금까지 데이터로 몬스터를 관리할 수 있는 패턴을 구현했습니다.

여기까지만 해도 나름 쓸만합니다. 하지만 이 방식에는 치명적인 단점이 있습니다. 뭘까요? 바로 데이터는 쉽게 정의할 수 있지만 동작을 정의하지 못한다는 점입니다.

요즘 메이플 몬스터들이 하나같이 다 멍청하게 움직이다가 스킬 한 대 맞고 훌륭한 경험치로 승화하기는 하지만, 예전에는 하나하나가 중간보스급 포스였고 각각의 개성이 있었죠.

무슨 말을 하고 싶은 거나면, 생각보다 많은 기능들이 들어가 있습니다.

이건 킹무위키에서 가져온 짜증내는 좀비버섯 스탯입니다. 우선 체력 레벨, 경험치 등등 당연히 있어야 하는 데이터가 있습니다.

근데 무슨 마나, 명중치, 회피치에 방어율은 물리, 마법으로 나눠져 있고 암흑 반감에 성 약점까지 있습니다. 전 메이플을 4년하면서 이게 있는지 처음 알았습니다.

제가 4년동안 몬스터 패시브를 몰랐다는 것도 큰 문제지만, 더 큰 문제는 우리의 빈약한 패턴으로는 저 패시브를 구현할 수 없다는 겁니다.

방어율, 회피치는 단순 데이터니까 그렇다 쳐도 약점/반감 패시브라든가 몬스터가 마법이라도 쓰려고 하는 순간 이 패턴은 쓰레기가 됩니다.

어떻게 해야 할까요? 지금이라도 이전에 만든 파일들을 휴지통에 갖다 버리고 "역시 상속이 정답이였어!" 를 외치면서 500개가 넘는 종족 클래스를 만들어야 할까요?

물론 그것도 하나의 방법이라고 할 수 있지만 다른 방법 역시 있습니다. 심지어 아이디어 자체는 굉장히 간단합니다.

바로, 미리 모든 행동을 정의하고 들어오는 데이터에 따라 다른 속성을 부여하면 됩니다.

이 뭔소리냐 하면, 암흑 반감을 넣고 싶으면 기획자는 데이터에 "암흑반감" 이라고 적습니다. 그러면 런타임에서 그에 맞는 동작을 붙여주는 겁니다.

말은 쉽지 이걸 어떻게 할까요? 바로 14장에서 소개된 "컴포넌트 패턴"을 사용하는 것입니다.

물론 이 글은 "타입 객체 패턴"을 다루기에 컴포넌트에 대한 말은 아낄 것입니다.

근데 사실 설명할 필요도 없습니다. 왜냐면 저희 게임 개발자만큼 컴포넌트 패턴에 익숙한 사람들도 없거든요.

유니티로 생각해봅시다. 저희가 만든 스크립트를 오브젝트가 사용하려면 어떻게 해야 하나요? 네 붙이면 됩니다.

예 이게 바로 컴포넌트죠. 대신 이걸 미리 하는게 아니라 런타임에 데이터에 맞게 적절한 컴포너트를 붙이록 코딩하면 됩니다.

Why Component?

컴포넌트 패턴에 대한 설명을 아낀다고 하기는 했지만, 왜 상속을 갖다 버리고 컴포넌트를 쓰는지에 대한 설명은 필요할거 같아서 햄버거 가게에 비유를 해서 짤막하게 설명해보겠습니다. 괸심 없으신 분은 구현부로 넘어가시면 됩니다. (혹시 이 글 자체에 관심이 없으신 분이 계시다면 정말 슬프지만 Alt + 왼쪽 방향키를 누르시면 됩니다.......)

상속은 분명 훌륭한 코드 재사용 도구이지만 유연하다고 보기는 힘듭니다.

그 이유는 크게 2가지입니다. 우선 런타임 전에 미리 정의되어 있습니다. 주황버섯, 슬라임같은 이름의 클래스를 만들어둬야 한다는 소리죠. 두 번째 요소는 '원 샷' 즉 한 번만 사용 가능한 도구라는 점입니다.

햄버거 가게에 가서 주문을 하려고 하는데 오직 세트 메뉴만 있다면 어떨까요? 10개의 햄버거 5개의 서브 메뉴 3개의 음료만 있어도 150개의 세트를 만들어야 합니다. 심지어 중복을 허용하면? 오, 답도 없군요.

다행히 현실은 이렇지 않습니다. 직접 고르고 조합해 자신만의 세트를 만들 수 있죠.

여기서 저희는 메뉴 개발자고 고객은 기획자 분들이라고 할 수 있습니다. 모든 걸 상속으로 만든다는 건 모든 세트메뉴를 만들어 놓는 것과 비슷합니다.

기획자 분이 새로운 몬스터를 만들고 싶으면 그때마다 저희를 찾아와야 합니다.

새로운 세트 메뉴를 고객이 건의는 할 수 있어도 직접 만들 순 없는 것처럼, 새로운 스크립트를 만들고 상속을 받고 관련 기능을 구현하는 것은 저희가 할 일이죠.

하지만 컴포넌트는 그럴 필요가 없습니다. 기획자 분들이 자신만의 새로운 종족을 저희의 도움 없이 만들 수 있죠.

요청받는 건 새로운 세트메뉴가 아니라 새로운 매뉴 하나입니다. 그리고 그 하나만 추가해도 수많은 조합을 새로 만들 수 있습니다.

바로 이 유연성을 고객에게 제공하는 것이 컴포넌트를 사용하는 가장 큰 이유입니다.

패시브 구현

그럼 한 번 패시브를 구현해보죠. 메이플에는 여러가지 패시브가 있지만 3개만 하도록 하겠습니다.

우선 아까 봤던 반감을 하죠. 그리고 몬스터가 죽으면 마치 새끼를 낳듯이 다른 몬스터가 스폰되는 패시브, 주변에 플레이어가 있으면 슬로우를 거는 패시브를 만들어 보겠습니다.

그럼 우선 Passive 클래스와 구분을 위한 enum을 추가하도록 하죠.

public enum MonsterPassiveType
{
    None,
    범위슬로우,
    새끼낳기,
 	암흑반감,
// enum을 한글로 적은 이유는 데이터 입력할 때 편하게 하기 위해서입니다.
}

[System.Serializable]
public class MonsterPassive
{
    [SerializeField] MonsterPassiveType _passiveType;
    public MonsterPassiveType PassiveType => _passiveType;

    [SerializeField] string[] _datas;
    public IReadOnlyList<string> Datas => _datas;
}

public class MonsterType
{
	// 나머지는 동일...
    [SerializeField] MonsterPassive[] _passives;
    public IReadOnlyList<MonsterPassive> Passives => _passives;
}

string[]인 _datas는 패시브를 사용하기 위한 데이터를 넣을 장소입니다. 이미지로 보자면 이렇게 됩니다.

넓게 보니 글자가 깨지는군요. 바뀐 부분만 봐봅시다.

짜증내는 좀비버섯은 슬로우와 암흑 반감을 가지고 있고, 슬라임은 죽으면 새끼를 낳습니다.

옆에 _datas는 패시브가 사용할 데이터입니다. 슬로우의 경우 5는 범위 30이 이속 감소율입니다. 새끼낳기 옆에 주황버섯은 말 그래로 주황버섯을 낳겠다는 뜻이고, 암흑반감의 50은 50퍼 감소라는 뜻입니다.

지금까지는 데이터만 입력했습니다. 이를 바탕으로 로직을 구현하도록 하죠.

우선 몬스터 클래스에서 어떻게 패시브를 작동시킬지 정해야 하는데. 저는 팩터리를 만들어서 거기서 가져오도록 하겠습니다.

public class MonsterPassiveFactory
{
	// 실제 패시브를 구현하는 클래스 MonsterPassives
    MonsterPassives passives = new MonsterPassives();
    public Action<Monster> GetMonsterPassive(MonsterPassiveType type, IReadOnlyList<string> datas)
    {
        switch (type)
        {
            case MonsterPassiveType.범위슬로우: return (monster) => passives.AreaEffectSlow(monster, datas[0], datas[1]);
            case MonsterPassiveType.새끼낳기: return (monster) => passives.BreedWhenDead(monster, datas[0]);
            case MonsterPassiveType.암흑반감: return (monster) => passives.DarkAntagonism(monster, datas[0]);
        }
        return null;
    }
}

팩터리는 passiveType에 따라 MonsterPassives에서 적절한 기능을 찾고 데이터를 매핑 후 Action을 반환합니다. 이를 몬스터에서 어떻게 사용하는지 보겠습니다.

public class Monster : MonoBehaviour
{
	// 필드는 동일
    public void SetInfo(MonsterType type)
    {
        _type = type;
        // 나머지는 동일
        foreach (var passive in _type.Passives)
            new MonsterPassiveFactory()
            	.GetMonsterPassive(passive.PassiveType, passive.Datas)?.Invoke(this);
    }
}

팩터리에서 받은 함수에 자신을 인자로 줘서 실행합니다. 몬스터는 패시브의 세부 구현을 모릅니다. 그 덕분에 언제든지 패시브를 바꿀 수 있습니다.

물론 Monster 클래스가 모르는 거지 저희는 세부 구현을 알아야 하니 코드를 보도록 하겠습니다.

class MonsterPassives
{
    public void AreaEffectSlow(Monster monster, string radiusText, string slowRateText)
    {
        Debug.Assert(float.TryParse(radiusText, out float radius), "float 데이터 입력 잘못한 듯?");
        Debug.Assert(float.TryParse(slowRateText, out float slowRate), "float 데이터 입력 잘못한 듯?");
        monster.gameObject.AddComponent<RangeSlower>().SetCollider(radius, slowRate);
    }
}

class RangeSlower : MonoBehaviour
{
    [SerializeField] float _slowRate;
    public void SetCollider(float radius, float slowRate)
    {
        var colldier = GetComponent<CircleCollider2D>();
        colldier.radius = radius;
        colldier.isTrigger = true;
        _slowRate = slowRate;
    }
    void OnTriggerEnter2D(Collider2D collision) => collision.GetComponent<Player>()?.Slow(_slowRate);
    void OnTriggerExit2D(Collider2D collision) => collision.GetComponent<Player>()?.ExitSlow();
}

먼저 슬로우부터 보도록 하겠습니다. AreaEffectSlow는 들어온 데이터를 검증 및 캐스팅 후 RangeSlower 컴포넌트를 붙이는 역할입니다.

RangeSlower는 Unity의 충돌 주기 이벤트를 통해 플레이어가 범위에 있으면 슬로우를 실행합니다.

RangeSlower는 컴포넌틑를 붙이는 식으로 구현하였습니다. 이는 Monster class입장에서 아무런 변화 없이 구현 가능합니다. RangeSlower가 필요한 건 플레이어와의 충돌 여부이지 몬스터에게 궁금한 건 없기 때문입니다.

하지만 새끼낳기나 암흑반감은 다릅니다. 몬스터에게 궁금한 것이 있죠. 새끼낳기는 몬스터가 죽는 순간이 필요합니다. 암흑반감은 몬스터가 대미지를 받는 순간과 받은 공격의 대미지, 타입이 궁금합니다.

이를 구현하기 위해서는 어쩔 수 없이 Monster에 추가 로직이 필요합니다. 하지만 몬스터가 패시브에 의존하는 건 원치 않습니다. 그러면 저희가 원했던 유연성을 형체도 알아보기 힘들 정도로 후드려 패는 짓이나 다름 없기 때문입니다. 그러니 저는 event를 이용하도록 하겠습니다.

public class Monster : MonoBehaviour
{
	// 나머지는 동일
    public void OnDamaged(int damage)
    {
        _currentHp -= damage;
        if (_currentHp <= 0)
            Dead();
    }

    public event Action OnDead;
    void Dead()
    {
        OnDead?.Invoke();
        Destroy(gameObject);
    }
}

먼저 새끼를 낳는것부터 보죠. 죽는 순간에 OnDead event를 실행합니다. 이는 패시브가 궁금해하던 죽는 순간에 대한 정보를 제공한 것입니다. 이제 패시브를 구현해보죠

class MonsterPassives
{
	// 나머지는 동일
    public void BreedWhenDead(Monster monster, string birthName) 
        => monster.OnDead += () => MonsterSpawner.SpawnMonster(birthName, monster.transform.position);
}

간단하게 죽는 순간에 몬스터 스폰 함수를 구독함으로써 구현했습니다. 이 뿐만 아니라 경험치 획득, 아이템 드랍, 퀘스트, 풀링 등 여러 로직에서도 사용 가능할 겁니다.

이번에는 암흑 반감입니다. 이 친구 같은 경우는 OnDamaged 구현을 바꿔야 합니다. 현재 대미지만 입력받고 있는데 이게 아니라 AttackType도 받아야 합니다.

이를 위해 새로운 코드를 작성하겠습니다.

public enum AttackType
{
    Default,
    Dark,
    Holy,
}

public class Attack
{
    public Attack(AttackType type, int damage)
    {
        _attackType = type;
        _damage = damage;
    }

    AttackType _attackType;
    public AttackType AttackType => _attackType;

    int _damage;
    public int Damage => _damage;
}

enum. 그리고 type과 damage를 들고 있는 Attack 클래스를 만들었습니다. Monster에서 이를 어떻게 활용하는지 보시죠.

public class Monster : MonoBehaviour
{
    // 나머지는 동일
    public event Func<int, AttackType, int> DamageCalculte = null;

    int DamageCalculteAll(Attack attack)
    {
        if (DamageCalculte == null) return attack.Damage;

        int result = attack.Damage;
        foreach (Func<int, AttackType, int> func in DamageCalculte.GetInvocationList())
            result = func.Invoke(result, attack.AttackType);
        return result;
    }
    
    public void OnDamaged(Attack attack)
    {
        _currentHp -= DamageCalculteAll(attack);
        if (_currentHp <= 0)
            Dead();
    }
}

OnDamaged 에서 int가 아닌 Attack을 받는 것 외에도 변경점이 있습니다. 바로 Func를 추가한 것이죠. 이는 방금 구현한 OnDead와 같지만, Func는 Action과 달리 그냥 Invoke()를 하면 마지막에 구독한 내용만 실행합니다.

그래서 GetInvocationList()를 통해 모든 구독자들을 가져와 foreach로 돌리면서 값을 갱신합니다.

패시브가 하나만 있으면 상관이 없지만 반감이 2개 이상일 수도 있고, 메이플에는 레밸 패널티나 지역에 따른 포스 패널티가 존재하기에 이것까지 포함한다면 저런식으로 여러 연산자를 실행하는 것이 물론 복잡하기는 하지만 나쁘지 않은 선택일 수 있습니다. (물론 레밸, 포스 패널티를 주려면 Player의 정보가 따로 필요합니다.)

이제 이를 이용해 암흑반감을 어떻게 구현하는지 봅시다.

class MonsterPassives
{
    public void DarkAntagonism(Monster monster, string rateText)
    {
        Debug.Assert(float.TryParse(rateText, out float rate), "float 데이터 입력 잘못한 듯?");
        monster.DamageCalculte += (damage, attackType) =>
        {
            if(attackType == AttackType.Dark) 
                damage -= Mathf.FloorToInt(damage * (0.01f * rate));
            return damage;
        };
    }
}

받은 데이터를 파싱하고, 조건 검사 후 맞다면 대미지를 깍은 후 리턴합니다.

전체 코드 및 테스트

이번 내용은 좀 길었으니 전체 코드와 실행 결과를 따로 보여드리도록 하겠습니다.

테스트

테스트를 위해 플레이어가 주변에 적에게 대미지를 주는 코드를 작성합시다.

public class Player : MonoBehaviour
{
    [SerializeField] float _originSpeed;
    [SerializeField] float _speed;
    public void Slow(float newSpeed) => _speed -= _speed * (0.01f * newSpeed);
    public void ExitSlow() => _speed = _originSpeed;

    void Awake()
    {
        StartCoroutine(Co_AttackRangeInMonsterLoop());
    }

    IEnumerator Co_AttackRangeInMonsterLoop()
    {
        while (true)
        {
            AttackRangeInMonster();
            yield return new WaitForSeconds(2);
        }
    }

    void AttackRangeInMonster()
    {
        IEnumerable<Monster> monsters = 
            Physics2D.CircleCastAll(transform.position, 10, Vector2.zero)
            .Where(x => x.transform.GetComponent<Monster>() != null)
            .Select(x => x.transform.GetComponent<Monster>());
        foreach (var monster in monsters)
        {
        	// 특성이 암흑이고 대미지가 10인 공격을 함.
            monster.OnDamaged(new Attack(AttackType.Dark, 10));
        }
    }
}

암흑반감 확인을 위해 대미지 감소 시 출력을 하도록 하겠습니다.

    int DamageCalculteAll(Attack attack)
    {
        if (DamageCalculte == null) return attack.Damage;

        int result = attack.Damage;
        foreach (Func<int, AttackType, int> func in DamageCalculte.GetInvocationList())
            result = func.Invoke(result, attack.AttackType);
        print($"대미지 감소되서 {result}됨");
        return result;
    }

DamageCalculte가 null이면 그냥 리턴하고 아니면 감소 후 값을 출력합니다.

참고로 플레이어는 아래 사진과 같은 컴포넌트를 가지고 있으며, 사각형입니다.

그럼 이제 몬스터를 소환 후 플레이어를 가운데에 놔두고 패시브들이 작 동작하는 살펴보겠습니다.

플레이어와 몬스터를 세팅했습니다.

가장 먼저 10을 줬던 대미지가 암흑반감으로 감소되서 5가되었다는 로그가 출력되었습니다.


그리고 플레이어의 스피드가 10에서 7로 정확히 30퍼센트 줄었습니다.

그리고 시간이 흐르자 슬라임이 죽고 주황버섯이 되었습니다. 첫 번째 사진과 비교해 보시면 왼쪽 위에 초록색 원이 주황색이 되었다는 것을 확인하실 수 있습니다.

다행히 패시브 3개 다 잘 작동하는군요!!

코드

마지막으로 전체 코드를 보죠. 우선 몬스터 패시브 관련 코드입니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public enum MonsterPassiveType
{
    None,
    범위슬로우,
    새끼낳기,
    암흑반감,
}

public class MonsterPassiveFactory
{
    MonsterPassives passives = new MonsterPassives();
    public Action<Monster> GetMonsterPassive(MonsterPassiveType type, IReadOnlyList<string> datas)
    {
        switch (type)
        {
            case MonsterPassiveType.범위슬로우: return (monster) => passives.AreaEffectSlow(monster, datas[0], datas[1]);
            case MonsterPassiveType.새끼낳기: return (monster) => passives.BreedWhenDead(monster, datas[0]);
            case MonsterPassiveType.암흑반감: return (monster) => passives.DarkAntagonism(monster, datas[0]);
        }
        return null;
    }
}

class MonsterPassives
{
    public void AreaEffectSlow(Monster monster, string radiusText, string slowRateText)
    {
        Debug.Assert(float.TryParse(radiusText, out float radius), "float 데이터 입력 잘못한 듯?");
        Debug.Assert(float.TryParse(slowRateText, out float slowRate), "float 데이터 입력 잘못한 듯?");
        monster.gameObject.AddComponent<RangeSlower>().SetCollider(radius, slowRate);
    }

    public void BreedWhenDead(Monster monster, string birthName) 
        => monster.OnDead += () => MonsterSpawner.SpawnMonster(birthName, monster.transform.position);

    public void DarkAntagonism(Monster monster, string rateText)
    {
        Debug.Assert(float.TryParse(rateText, out float rate), "float 데이터 입력 잘못한 듯?");
        monster.DamageCalculte += (damage, attackType) =>
        {
            if(attackType == AttackType.Dark) 
                damage -= Mathf.FloorToInt(damage * (0.01f * rate));
            return damage;
        };
    }
}

class RangeSlower : MonoBehaviour
{
    [SerializeField] float _slowRate;
    public void SetCollider(float radius, float slowRate)
    {
        var colldier = GetComponent<CircleCollider2D>();
        colldier.radius = radius;
        colldier.isTrigger = true;
        _slowRate = slowRate;
    }
    void OnTriggerEnter2D(Collider2D collision) => collision.GetComponent<Player>()?.Slow(_slowRate);
    void OnTriggerExit2D(Collider2D collision) => collision.GetComponent<Player>()?.ExitSlow();
}

이번엔 Monster 코드입니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Monster : MonoBehaviour
{
    [SerializeField] MonsterType _type;
    public MonsterType Type => _type;
    [SerializeField] int _currentHp;
    
    public void SetInfo(MonsterType type)
    {
        _type = type;
        gameObject.name = _type.Name;
        _currentHp = _type.Hp;
        GetComponent<SpriteRenderer>().color = type.Color;

        foreach (var passive in _type.Passives)
            new MonsterPassiveFactory().GetMonsterPassive(passive.PassiveType, passive.Datas)?.Invoke(this);
    }

    int DamageCalculteAll(Attack attack)
    {
        if (DamageCalculte == null) return attack.Damage;

        int result = attack.Damage;
        foreach (Func<int, AttackType, int> func in DamageCalculte.GetInvocationList())
            result = func.Invoke(result, attack.AttackType);
        return result;
    }

    public void OnDamaged(Attack attack)
    {
        _currentHp -= DamageCalculteAll(attack);
        if (_currentHp <= 0)
            Dead();
    }

    public event Action OnDead;
    void Dead()
    {
        OnDead?.Invoke();
        Destroy(gameObject);
    }
}

끝으로 Attack과 Player관련 코드입니다.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;

public enum AttackType
{
    Default,
    Dark,
    Holy,
}

public class Attack
{
    public Attack(AttackType type, int damage)
    {
        _attackType = type;
        _damage = damage;
    }

    AttackType _attackType;
    public AttackType AttackType => _attackType;

    int _damage;
    public int Damage => _damage;
}

public class Player : MonoBehaviour
{
    [SerializeField] int _level;
    public int Level => _level;

    [SerializeField] float _originSpeed;
    [SerializeField] float _speed;
    public void Slow(float newSpeed) => _speed -= _speed * (0.01f * newSpeed);
    public void ExitSlow() => _speed = _originSpeed;

    void Awake()
    {
        StartCoroutine(Co_AttackRangeInMonsterLoop());
    }

    IEnumerator Co_AttackRangeInMonsterLoop()
    {
        while (true)
        {
            AttackRangeInMonster();
            yield return new WaitForSeconds(2);
        }
    }

    void AttackRangeInMonster()
    {
        IEnumerable<Monster> monsters = 
            Physics2D.CircleCastAll(transform.position, 10, Vector2.zero)
            .Where(x => x.transform.GetComponent<Monster>() != null)
            .Select(x => x.transform.GetComponent<Monster>());
        foreach (var monster in monsters)
        {
            monster.OnDamaged(new Attack(AttackType.Dark, 10));
        }
    }
}

데이터 상속

자 이제 드디어 동작도 정의했습니다. 이제 수백 수천개의 몬스터를 정의해도 문제가 없을 겁니다.

근데..... 정말 없을까요? 가슴에 손을 얹고 생각해보죠..... 진짜 없나?

잘 생각해보니 있습니다. 만약 수백개가 넘는 종족을 만들면 비슷한 특성을 가진 객체끼리 한꺼번에 수치를 건드리는 일이 많을 겁니다. 이걸 하나하나 수정하려면 꽤나 많은 데이터를 반복해서 고쳐야 합니다.

이런 경우에는 비슷한 데이터끼리 서로 공유한다면 중복을 피할 수 있을 것입니다. 근데 이거 우리 어디서 많이 보지 않았나요? 비슷한 특성을 가진 객체끼리 중복을 피하기 위해 하는 것. 바로 상속입니다.

저희가 상속을 구현하는 겁니다! 다만 저희는 데이터만 상속한다는 차이점이 있죠. 간단하게 단일 상속만 지원해봅시다. (참고로 이 내용은 책에서 그대로 가져온 대사입니다. 당시 패턴의 컨셉도 제대로 이해하지 못하고 있었던 저는 갑자기 상속을 구현한다는 글을 읽는 순간 멘탈이 터져버리고 말았습니다. 여러분은 어떤가요?)

원하는 건 이런 모습입니다.

보시면 새로운 필드인 _parent가 생겼습니다. 그리고 몇몇 필드들이 비어 있습니다. 이는 상속을 구현하여 공유한 것인데요. 초록버섯은 부모인 주황버섯의 레밸과 체력을 사용합니다. 하지만 대미지는 따로 자신이 정의합니다.

짜증내는 좀비버섯도 부모인 좀비버섯에게 채력과 패시브를 상속받고 나머지는 자신이 정의합니다. 이럴 경우의 장점은 역시 비슷한 특성을 공유하는 종족의 중복 데이터가 줄어든다는 점입니다.

물론 이렇게까지 해야하나 싶을 수 있지만, 코딩을 한다고 생각해봅시다. 저희는 중복을 제거하기 위해 함수, 상속 등 당연하다고 여겨지는 수준의 작업을 일상적으로 합니다.

근데 저희가 코드에서라면 절대 용납하지 않은 중복을 기획자에게 전가하고 있는 겁니다. 당연하게도 단순한 반복 작업을 좋아하는 사람은 없을 겁니다.

그럼 상속을 구현해보도록 하죠. 그럼 오랜만에 MonsterType 클래스를 봅시다.

public class MonsterType
{
    [SerializeField] string _name;
    public string Name => _name;

    [SerializeField] string _parent;
    public string Parent => _parent;

    [SerializeField] int _level;
    public int Level => _level;

    [SerializeField] int _hp;
    public int Hp => _hp;

    [SerializeField] int _damage;
    public int Damage => _damage;

    [SerializeField] Color32 _color;
    public Color32 Color => _color;

    [SerializeField] MonsterPassive[] _passives;
    public IReadOnlyList<MonsterPassive> Passives => _passives;

	// 추가된 부분
    public void OverrideParnet(MonsterType parent)
    {
    	Debug.Assert(_parent == parent.Name, "이상한 사람이 부모라고 함(유괴인 듯?)");
        
        if (_level == 0) _level = parent._level;
        if (_hp == 0) _hp = parent._hp;
        if (_damage == 0) _damage = parent._damage;
        if (_color.a == 0) _color = parent._color;
        if (_passives.Length == 0) _passives = parent._passives;
    }
}

오랜만에 보는 친구기 때문에 전체 코드를 들고 왔습니다. OverrideParnet() 함수는 parent를 인자값으로 받고 자신의 값이 유효하지 않으면 부모의 값을 덮어씁니다.

MonsterType은 여기서 끝입니다. 이제 누군가가 OverrideParnet()을 호출해줘야 하는데 이는 스포너에서 하도록 하겠습니다.

using System.Linq;

public class MonsterSpawner : MonoBehaviour
{
	// 필드는 동일
    void Awake()
    {
        _nameByMonsterType 
        	= CsvUtility.CsvToArray<MonsterType>(monsterTypes.text).ToDictionary(x => x.Name, x => x);
        OverrideMonsterData();

    }
    
    void OverrideMonsterData()
    {
        _nameByMonsterType
            .Values 
            .Where(x => string.IsNullOrEmpty(x.Parent) == false) // 부모가 있는 애들만 걸러냄. (욕 아님)
            .ToList()
            .ForEach(x => x.OverrideParnet(_nameByMonsterType[x.Parent])); // 값 오버라이드
    }
    
    // 나머지는 동일
}

우선 _nameByMonsterType에 데이터를 로드합니다. 그 후 모든 MonsterType에 Parent필드 값을 확인 후 누군가에게 상속을 받고 있다면 OverrideParnet를 실행합니다.

데이터 분리

데이터 상속을 구현할 때 중복에 대해서 언급했습니다. 근데 저희가 코딩할 때 용납하지 않는 것이 하나 더 데이터에서 보이고 있습니다. 데이터 이미지를 다시 한 번 보시죠.

패시브를 한 번 봐보죠. 좀비버섯류만 패시브를 사용하고 있습니다. 근데 주황, 초록버섯도 _passives필드를 들고 있습니다. 문제는 저게 가독성을 떨어트리고 있다는 겁니다. 물론 코드에서는 딸랑 필드 하나지만 데이터를 입력할 때는 상당히 불편합니다. 만약 코드에서 이런 문제가 발생한다면 저희는 분리를 합니다. 사실 이미 하고 있습니다.

[System.Serializable]
public class MonsterPassive
{
    [SerializeField] MonsterPassiveType _passiveType;
    public MonsterPassiveType PassiveType => _passiveType;

    [SerializeField] string[] _datas;
    public IReadOnlyList<string> Datas => _datas;
}

public class MonsterType
{
	// 나머지는 동일
    [SerializeField] MonsterPassive[] _passives;
    public IReadOnlyList<MonsterPassive> Passives => _passives;
}

보이시나요? 원래 MonsterPassive는 2개의 필드를 들고 있습니다. 하지만 저희는 이걸 하나의 class로 묶고 MonsterPassive라는 하나의 필드로 들고 있습니다. 데이터에서도 이런 분리를 원합니다.

원하는 결과는 이렇습니다.

기존에 패시브를 정의하던 필드가 없어졌습니다.

대신에 다른 파일로 이사왔습니다. 이 두 파일을 합쳐야 합니다. 이걸 코드로 구현해보죠.

public class MonsterSpawner : MonoBehaviour
{
	// 윗부분은 동일
    
    [SerializeField] TextAsset monsterTypes;
    [SerializeField] TextAsset passiveData; // 추가된 패시브 파일 데이터

    static Dictionary<string, MonsterType> _nameByMonsterType = new Dictionary<string, MonsterType>();
    Dictionary<string, IReadOnlyList<MonsterPassive>> _nameByPassives = new Dictionary<string, IReadOnlyList<MonsterPassive>>();
    void Awake()
    {
        _nameByMonsterType = CsvUtility.CsvToArray<MonsterType>(monsterTypes.text).ToDictionary(x => x.Name, x => x);
        
        SetPassives();
        OverrideMonsterData();
    }

    void SetPassives()
    {
    	var _nameByPassives = CsvUtility.CsvToArray<MonsterType>(passiveData.text).ToDictionary(x => x.Name, x => x.Passives);
        foreach (var type in _nameByMonsterType.Values)
        {
            if (_nameByPassives.TryGetValue(type.Name, out IReadOnlyList<MonsterPassive> passives) == false) 
            	continue;
            type.SetPassive(passives.ToArray());
        }
    }
    
    // 나머지는 동일
}

상속 전에 새로운 데이터인 passiveData를 로드해서 type에 넣습니다. SetPassive()는 단순한 세터 함수입니다.

패시브 세팅 후 상속을 진행하기 때문에 짜증나는 좀비 버섯은 따로 패시브를 데이터에 선언하지 않아도 좀비 버섯의 패시브를 상속받습니다.

근데 이게 또 말만하면 믿을수가 없으니 한 번 보고 가도록 하겠습니다. 먼저 좀비 버섯부터 보죠.

다음에는 짜증내는 좀비버섯이 잘 상속받았는지 보도록 하겠습니다.

다행히 잘 받았습니다!

장단점, 언제 써야 하는가

마지막으로 장단점을 정리하는 시간을 가져보도록 하겠습니다. 근데 사실 앞에서는 장점 위주로 얘기했기 때문에 단점을 주로 언급할 겁니다.

마지막에 와서 단점들을 언급하는 이유는 여러분이 "와 알고보니 개쓰레기였네! 시간만 날렸다!" 같은 허탈한 감정을 느끼게 하기 위해서가 아닙니다. 이유는 단순함이 최고의 가치이기 때문입니다.

이 패턴을 사용하면 당연하지만 어느 정도 비용이 들어갑니다. 상속같은 시스템에서 기본적으로 해 주던 것들을 힘들게 힘들게 직접 구현해야 합니다.

제가 위해서 했던 작업들. 동작을 구현하기 위해 컴포넌트 패턴을 쓰거나 데이터 상속, 분리를 구현한 건 사실 상속에서 해주던 걸 구현하기 위한 발버둥이었습니다.

그리고 얼렁뚱땅 넘어가기는 했지만 데이터를 로드하는 프로그램도 따로 작성해야 합니다. Json같은 포멧을 사용한다면 훌륭한 라이브러리가 많이 있지만 아무래도 Csv가 편한 부분이 있다보니 따로 Csv를 Json으로 변환하는 작업이 필요할 수도 있습니다. (아니면 CsvUtility쓰실?)

또한 프로젝트는 케바케입니다. 여러분만의 프로젝트에서는 다른 문제가 생길 수 있고 그 문제를 해결하기 위해 또 다른 구현이 필요할 수 있습니다.

이 모든 건 비용이며 시간과 노력을 투자해야 합니다.

그럼 이렇게 해서 얻는 건 뭘까요? 처음부터 계속 말했던 유연함입니다. 상속이라는 훌륭한 도구를 놔두고 이 패턴을 굳이 사용한 근본적인 이유는 상속의 한계를 넘기 위해서입니다.
됩니다.

하지만 딱 3개의 메뉴만으로도 잘 되는 식당이 있듯이 유연성이 필수는 아닙니다.

이 패턴을 하나하나 구현하는 것보다 몬스터를 하나하나 상속받아 구현하는 것이 비용이 더 크다고 느껴지신다면 그때가 바로 이 패턴을 사용할 순간입니다.

필요하다고 결론이 나셨다면, 우선 적정 수준만 적용하고 상황을 보면서 추가로 구현하면 됩니다. 경우에 따라서는 여러분만의 기능을 구현을 할 수도 있습니다.

이 글의 목적은 이미 있는 해결책은 가져다 쓰고 새로운 것을 만드는 것에 시간을 투자하게 하기 위함입니다.

만약 새로운 컨텐츠를 만들어야 된다고 할 때마다 나오는 한숨의 크기, 부담감, 들이는 시간을 줄이셨다면, 이 패턴을 굉장히 훌륭하게 사용하신 겁니다.

profile
수강신청 망친 새내기 개발자

0개의 댓글